/**
 * RepoIndex: knowledge graph with metadata, save/load via SQLite.
 * Backward compatible: detects legacy msgpack (RPIDX magic) and migrates.
 */

import { readFileSync, writeFileSync, copyFileSync, existsSync, statSync, readdirSync } from "node:fs";
import { resolve, join } from "node:path";
import { decode } from "@msgpack/msgpack";
import { IndexCorruptedError, IndexVersionError } from "../errors";
import { KnowledgeGraph } from "./knowledge-graph";

const INDEX_VERSION = 2;
const LEGACY_MAGIC = Buffer.from("RPIDX", "ascii");

export class RepoIndex {
  graph: KnowledgeGraph;
  private _metadata: Record<string, unknown>;

  constructor(
    graph: KnowledgeGraph,
    metadata?: Record<string, unknown>,
  ) {
    this.graph = graph;
    this._metadata = metadata ?? {};
    if (!("build_timestamp" in this._metadata)) {
      this._metadata.build_timestamp = Date.now() / 1000;
    }
    if (!("version" in this._metadata)) {
      this._metadata.version = INDEX_VERSION;
    }
  }

  get metadata(): Record<string, unknown> {
    return {
      ...this._metadata,
      node_count: this.graph.nodeCount,
      edge_count: this.graph.edgeCount,
    };
  }

  save(path: string): void {
    // Persist metadata into the SQLite metadata table
    const db = this.graph.db;
    const upsert = db.prepare(`INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)`);
    const saveMeta = db.transaction(() => {
      upsert.run("version", String(INDEX_VERSION));
      for (const [key, value] of Object.entries(this._metadata)) {
        upsert.run(key, JSON.stringify(value));
      }
    });
    saveMeta();

    // Checkpoint WAL to ensure all data is in the main DB file
    db.pragma("wal_checkpoint(TRUNCATE)");

    // Determine source path — if the graph DB is in-memory, serialize synchronously
    const dbPath = db.name;
    if (dbPath === ":memory:" || dbPath === "") {
      // In-memory DB: serialize to Buffer and write synchronously
      const serialized = db.serialize();
      writeFileSync(path, serialized);
      console.log(
        `[index.saved] path=${path} nodes=${this.graph.nodeCount} edges=${this.graph.edgeCount}`,
      );
    } else if (resolve(dbPath) !== resolve(path)) {
      copyFileSync(dbPath, path);
      // Also copy WAL/SHM if they exist (shouldn't after checkpoint, but be safe)
      for (const suffix of ["-wal", "-shm"]) {
        if (existsSync(dbPath + suffix)) {
          copyFileSync(dbPath + suffix, path + suffix);
        }
      }
      console.log(
        `[index.saved] path=${path} nodes=${this.graph.nodeCount} edges=${this.graph.edgeCount}`,
      );
    } else {
      // Already writing to the target path — just checkpoint is enough
      console.log(
        `[index.saved] path=${path} nodes=${this.graph.nodeCount} edges=${this.graph.edgeCount}`,
      );
    }
  }

  static load(path: string): RepoIndex {
    const raw = readFileSync(path);

    // Detect legacy msgpack format
    if (raw.length >= LEGACY_MAGIC.length && raw.subarray(0, LEGACY_MAGIC.length).equals(LEGACY_MAGIC)) {
      return RepoIndex._loadLegacy(path, raw);
    }

    // SQLite format: open directly
    return RepoIndex._loadSqlite(path);
  }

  private static _loadSqlite(path: string): RepoIndex {
    let graph: KnowledgeGraph;
    try {
      graph = new KnowledgeGraph(path);
    } catch (e) {
      throw new IndexCorruptedError(
        path,
        `Failed to open SQLite DB: ${e instanceof Error ? e.message : e}`,
      );
    }

    // Verify it has our schema
    const tables = graph.db
      .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name IN ('nodes','edges','metadata')`)
      .all() as { name: string }[];
    if (tables.length < 3) {
      graph.close();
      throw new IndexCorruptedError(path, "Missing required tables (nodes, edges, metadata)");
    }

    // Load metadata
    const metaRows = graph.db
      .prepare(`SELECT key, value FROM metadata`)
      .all() as { key: string; value: string }[];
    const metadata: Record<string, unknown> = {};
    for (const row of metaRows) {
      try {
        metadata[row.key] = JSON.parse(row.value);
      } catch {
        metadata[row.key] = row.value;
      }
    }

    const version = (metadata.version as number) ?? 0;
    if (version > INDEX_VERSION) {
      graph.close();
      throw new IndexVersionError(INDEX_VERSION, version);
    }

    console.log(
      `[index.loaded] path=${path} nodes=${graph.nodeCount} edges=${graph.edgeCount}`,
    );
    return new RepoIndex(graph, metadata);
  }

  private static _loadLegacy(path: string, raw: Buffer): RepoIndex {
    console.log(`[index.legacy_migration] Migrating msgpack index to SQLite: ${path}`);

    let payload: Record<string, unknown>;
    try {
      payload = decode(raw.subarray(LEGACY_MAGIC.length)) as Record<string, unknown>;
    } catch (e) {
      throw new IndexCorruptedError(
        path,
        `Failed to decode legacy format: ${e instanceof Error ? e.message : e}`,
      );
    }

    const version = (payload.version as number) ?? 0;
    if (version !== 1) {
      throw new IndexVersionError(1, version);
    }

    const graphData = payload.graph as {
      nodes: Record<string, unknown>[];
      edges: Record<string, unknown>[];
    };
    const graph = KnowledgeGraph.fromSerializable(graphData);
    const metadata = (payload.metadata as Record<string, unknown>) ?? {};

    console.log(
      `[index.loaded] path=${path} nodes=${graph.nodeCount} edges=${graph.edgeCount} (migrated from legacy)`,
    );
    return new RepoIndex(graph, metadata);
  }

  checkStaleness(sourcePath: string): {
    isStale: boolean;
    added: string[];
    deleted: string[];
    modified: string[];
    buildTimestamp: number;
  } {
    const resolved = resolve(sourcePath);
    const buildTime = (this._metadata.build_timestamp as number) ?? 0;

    const currentFiles = new Set<string>();
    collectJavaFiles(resolved, currentFiles);

    const indexedFiles = new Set<string>(
      (this._metadata.source_files as string[]) ?? [],
    );

    const added = [...currentFiles].filter((f) => !indexedFiles.has(f)).sort();
    const deleted = [...indexedFiles].filter((f) => !currentFiles.has(f)).sort();

    const modified: string[] = [];
    for (const f of currentFiles) {
      if (!indexedFiles.has(f)) continue;
      try {
        if (statSync(f).mtimeMs / 1000 > buildTime) {
          modified.push(f);
        }
      } catch {
        // ignore
      }
    }
    modified.sort();

    const isStale = added.length > 0 || deleted.length > 0 || modified.length > 0;
    return { isStale, added, deleted, modified, buildTimestamp: buildTime };
  }
}

function collectJavaFiles(dir: string, out: Set<string>): void {
  let entries;
  try {
    entries = readdirSync(dir, { withFileTypes: true });
  } catch {
    return;
  }
  for (const entry of entries) {
    if (entry.name.startsWith(".")) continue;
    const full = join(dir, entry.name);
    if (entry.isDirectory()) {
      collectJavaFiles(full, out);
    } else if (entry.name.endsWith(".java")) {
      out.add(full);
    }
  }
}
