/**
 * QueryEngine: hybrid keyword + graph traversal query engine.
 */

import type { QueryConfig } from "../config";
import { DEFAULT_QUERY_CONFIG } from "../config";
import type { RepoIndex } from "../graph/index";
import type { KnowledgeGraph } from "../graph/knowledge-graph";
import {
  EdgeType,
  NodeType,
  type ContextItem,
  type NodeId,
  type StructuredContext,
} from "../types";

const CHARS_PER_TOKEN = 4;

const TYPE_WEIGHTS: Record<string, number> = {
  [NodeType.CLASS]: 1.0,
  [NodeType.INTERFACE]: 0.9,
  [NodeType.CBS_SCHEMA]: 1.0,
  [NodeType.METHOD]: 0.8,
  [NodeType.FIELD]: 0.5,
  [NodeType.CONSTRUCTOR]: 0.6,
  [NodeType.ENUM]: 0.7,
  [NodeType.FILE]: 0.3,
  [NodeType.PACKAGE]: 0.2,
};

export class QueryEngine {
  readonly _index: RepoIndex;
  private _config: QueryConfig;

  constructor(index: RepoIndex, config?: Partial<QueryConfig>) {
    this._index = index;
    this._config = { ...DEFAULT_QUERY_CONFIG, ...config };
  }

  query(question: string, maxTokens?: number): StructuredContext {
    const start = performance.now();
    const budget = maxTokens ?? this._config.maxTokens;
    const graph = this._index.graph;

    // 1. Extract query terms
    const terms = extractTerms(question);

    // 2. Match nodes by keywords
    const scoredNodes = matchNodes(terms, graph);

    // 3. Expand via graph traversal
    const expanded = expandContext(scoredNodes, graph);

    // 4. Rank results
    const ranked = [...expanded.entries()]
      .sort((a, b) => b[1] - a[1]);

    // 5. Budget tokens and build context items
    const items = buildContextItems(
      ranked,
      graph,
      budget,
      this._config.includeSnippets,
      this._config.includeLineNumbers,
    );

    const totalEstimate = items.reduce((sum, item) => sum + estimateTokens(item), 0);

    const elapsed = Math.round(performance.now() - start);
    console.log(
      `[query.executed] question="${question.slice(0, 100)}" terms=[${terms.join(",")}] results=${items.length} tokens=${totalEstimate} latency=${elapsed}ms`,
    );

    return {
      query: question,
      items,
      totalTokensEstimate: totalEstimate,
      suggestedQueries: [],
    };
  }
}

const STOP_WORDS = new Set([
  "the", "a", "an", "is", "are", "was", "were", "what", "how", "does",
  "do", "which", "where", "when", "who", "this", "that", "these", "those",
  "in", "on", "at", "to", "for", "of", "with", "by", "from", "and", "or",
  "not", "it", "its", "be", "has", "have", "had", "can", "could", "will",
  "would", "should", "may", "might", "about", "through",
]);

function extractTerms(question: string): string[] {
  const words = question.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
  const terms = words.filter((w) => !STOP_WORDS.has(w.toLowerCase()) && w.length > 1);

  // Also split camelCase terms
  const expanded: string[] = [];
  for (const term of terms) {
    expanded.push(term);
    const parts = term.match(/[A-Z]+(?=[A-Z][a-z])|[A-Z][a-z]+|[A-Z]+|[a-z]+/g);
    if (parts && parts.length > 1) {
      expanded.push(...parts);
    }
  }

  // Dedupe preserving order
  return [...new Map(expanded.map((t) => [t, t])).values()];
}

function matchNodes(
  terms: string[],
  graph: KnowledgeGraph,
): Map<NodeId, number> {
  const scored = new Map<NodeId, number>();

  for (const term of terms) {
    const matches = graph.searchNodes(term);
    for (const nodeId of matches) {
      const attrs = graph.getNodeAttrs(nodeId);
      const nodeType = (attrs.type as string) || "";
      const weight = TYPE_WEIGHTS[nodeType] ?? 0.5;
      const name = (attrs.name as string) || "";

      let score: number;
      if (term.toLowerCase() === name.toLowerCase()) {
        score = 2.0 * weight;
      } else if (name.toLowerCase().includes(term.toLowerCase())) {
        score = 1.0 * weight;
      } else {
        score = 0.5 * weight;
      }

      scored.set(nodeId, (scored.get(nodeId) || 0) + score);
    }
  }

  return scored;
}

function expandContext(
  scoredNodes: Map<NodeId, number>,
  graph: KnowledgeGraph,
  maxExpansion = 30,
): Map<NodeId, number> {
  const expanded = new Map(scoredNodes);
  const sortedNodes = [...scoredNodes.entries()].sort((a, b) => b[1] - a[1]);

  const expandEdgeTypes = [
    EdgeType.CONTAINS, EdgeType.CALLS, EdgeType.POPULATES,
    EdgeType.REFERENCES, EdgeType.EXTENDS, EdgeType.IMPLEMENTS,
  ];
  const hopEdgeTypes = [EdgeType.CALLS, EdgeType.POPULATES];

  for (const [nodeId, baseScore] of sortedNodes.slice(0, 15)) {
    // 1-hop: direct relationships
    for (const edgeType of expandEdgeTypes) {
      const neighbors = graph.getNeighbors(nodeId, edgeType, "both");
      for (const neighborId of neighbors) {
        if (!expanded.has(neighborId)) {
          const hopScore = baseScore * 0.5;
          expanded.set(
            neighborId,
            Math.max(expanded.get(neighborId) || 0, hopScore),
          );
          if (expanded.size >= maxExpansion + scoredNodes.size) {
            return expanded;
          }
        }
      }
    }

    // 2-hop
    for (const edgeType of hopEdgeTypes) {
      const neighbors = graph.getNeighbors(nodeId, edgeType, "out");
      for (const n1 of neighbors.slice(0, 5)) {
        const n2List = graph.getNeighbors(n1, edgeType, "out");
        for (const n2 of n2List.slice(0, 3)) {
          if (!expanded.has(n2)) {
            expanded.set(n2, Math.max(expanded.get(n2) || 0, baseScore * 0.25));
          }
        }
      }
    }
  }

  return expanded;
}

function buildContextItems(
  ranked: [NodeId, number][],
  graph: KnowledgeGraph,
  maxTokens: number,
  includeSnippets: boolean,
  includeLineNumbers: boolean,
): ContextItem[] {
  const items: ContextItem[] = [];
  let tokenBudget = maxTokens;

  for (const [nodeId] of ranked) {
    if (tokenBudget <= 0) break;

    let attrs: Record<string, unknown>;
    try {
      attrs = graph.getNodeAttrs(nodeId);
    } catch {
      continue;
    }

    const name = (attrs.name as string) || "";
    const nodeType = (attrs.type as string) || "unknown";
    const filePath = attrs.file_path as string | undefined;
    const lineStart = includeLineNumbers ? (attrs.line_start as number | undefined) : undefined;
    const lineEnd = includeLineNumbers ? (attrs.line_end as number | undefined) : undefined;

    // Build summary
    const summaryParts = [`${nodeType}: ${name}`];
    if (attrs.extends) summaryParts.push(`extends ${attrs.extends}`);
    if (attrs.implements) {
      summaryParts.push(`implements ${(attrs.implements as string[]).join(", ")}`);
    }
    if (attrs.field_count) summaryParts.push(`${attrs.field_count} CBS fields`);
    if (attrs.parameters) {
      summaryParts.push(`params: ${(attrs.parameters as string[]).slice(0, 3).join(", ")}`);
    }
    const summary = summaryParts.join(" | ");

    // Get snippet
    let snippet = includeSnippets ? (attrs.snippet as string | undefined) : undefined;

    // Get relationships
    const relationships: string[] = [];
    for (const edgeType of Object.values(EdgeType)) {
      const neighbors = graph.getNeighbors(nodeId, edgeType, "out");
      for (const n of neighbors.slice(0, 5)) {
        try {
          const nAttrs = graph.getNodeAttrs(n);
          relationships.push(`${edgeType} -> ${nAttrs.name || n}`);
        } catch {
          // skip
        }
      }
    }

    // CBS schema fields
    if (attrs.fields) {
      const fieldNames = (attrs.fields as { name: string }[])
        .slice(0, 20)
        .map((f) => f.name);
      relationships.push(`CBS fields: ${fieldNames.join(", ")}`);
    }

    const item: ContextItem = {
      nodeType,
      name,
      summary,
      filePath,
      lineStart,
      lineEnd,
      snippet,
      relationships: relationships.slice(0, 10),
    };

    let itemTokens = estimateTokens(item);
    if (itemTokens > tokenBudget) {
      item.snippet = undefined;
      itemTokens = estimateTokens(item);
      if (itemTokens > tokenBudget) continue;
    }

    items.push(item);
    tokenBudget -= itemTokens;
  }

  return items;
}

function estimateTokens(item: ContextItem): number {
  let chars = item.name.length + item.summary.length;
  if (item.filePath) chars += item.filePath.length;
  if (item.snippet) chars += item.snippet.length;
  chars += item.relationships.reduce((s, r) => s + r.length, 0);
  return Math.max(1, Math.floor(chars / CHARS_PER_TOKEN));
}
