/**
 * @file Tests for the Import History feature — the generalized directory
 * importer and the /api/import routes. Verifies that token counts and cost
 * computations come out identical between auto-import and manual import
 * for the same JSONL fixtures, that re-imports are idempotent, and that
 * archive extraction rejects path-traversal entries.
 *
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const zlib = require("zlib");

const TEST_DB = path.join(os.tmpdir(), `dashboard-import-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;

const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const importHistory = require("../../scripts/import-history");
const archive = require("../lib/archive");

let server;
let BASE;

function fetch(urlPath, options = {}) {
  return new Promise((resolve, reject) => {
    const url = new URL(urlPath, BASE);
    const opts = {
      hostname: url.hostname,
      port: url.port,
      path: url.pathname + url.search,
      method: options.method || "GET",
      headers: { "Content-Type": "application/json", ...options.headers },
    };
    const req = http.request(opts, (res) => {
      let body = "";
      res.on("data", (c) => (body += c));
      res.on("end", () => {
        let parsed;
        try {
          parsed = JSON.parse(body);
        } catch {
          parsed = body;
        }
        resolve({ status: res.statusCode, body: parsed, headers: res.headers });
      });
    });
    req.on("error", reject);
    if (options.body) req.write(JSON.stringify(options.body));
    req.end();
  });
}

function post(urlPath, body) {
  return fetch(urlPath, { method: "POST", body });
}

// ────────────────────────────────────────────────────────────────────────────
// Fixtures — deterministic JSONL sessions with known token counts so we can
// assert imported values match byte-for-byte.
// ────────────────────────────────────────────────────────────────────────────

const SESSION_A = "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa";
const SESSION_B = "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb";

function fixtureLines(sessionId, cwd, model, inputTok, outputTok) {
  const base = "2026-04-18T12:00:00.000Z";
  return [
    { type: "user", cwd, sessionId, timestamp: base, message: { content: "hi" } },
    {
      type: "assistant",
      cwd,
      sessionId,
      timestamp: base,
      message: {
        model,
        content: [{ type: "text", text: "ok" }],
        usage: {
          input_tokens: inputTok,
          output_tokens: outputTok,
          cache_read_input_tokens: 0,
          cache_creation_input_tokens: 0,
        },
      },
    },
    {
      type: "assistant",
      cwd,
      sessionId,
      timestamp: "2026-04-18T12:00:01.000Z",
      message: {
        model,
        content: [{ type: "tool_use", name: "Read", input: { file_path: "/tmp/foo" } }],
        usage: {
          input_tokens: inputTok,
          output_tokens: outputTok,
          cache_read_input_tokens: 10,
          cache_creation_input_tokens: 20,
        },
      },
    },
  ];
}

function writeFixtureDir() {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-fixture-"));
  const projDir = path.join(root, "-Users-demo-project");
  fs.mkdirSync(projDir, { recursive: true });
  fs.writeFileSync(
    path.join(projDir, `${SESSION_A}.jsonl`),
    fixtureLines(SESSION_A, "/Users/demo/project", "claude-opus-4-7", 100, 50)
      .map((o) => JSON.stringify(o))
      .join("\n")
  );
  fs.writeFileSync(
    path.join(projDir, `${SESSION_B}.jsonl`),
    fixtureLines(SESSION_B, "/Users/demo/project", "claude-sonnet-4-6", 200, 100)
      .map((o) => JSON.stringify(o))
      .join("\n")
  );
  return root;
}

before(async () => {
  const app = createApp();
  server = await startServer(app, 0);
  BASE = `http://127.0.0.1:${server.address().port}`;
});

after(() => {
  if (server) server.close();
  if (db) db.close();
  for (const suffix of ["", "-wal", "-shm"]) {
    try {
      fs.unlinkSync(TEST_DB + suffix);
    } catch {
      /* ignore */
    }
  }
});

// ────────────────────────────────────────────────────────────────────────────

describe("GET /api/import/guide", () => {
  it("returns OS-aware instructions and supported extensions", async () => {
    const res = await fetch("/api/import/guide");
    assert.equal(res.status, 200);
    assert.equal(typeof res.body.default_projects_dir, "string");
    assert.ok(res.body.supported_extensions.includes(".jsonl"));
    assert.ok(res.body.supported_extensions.includes(".tar.gz"));
    assert.ok(res.body.supported_extensions.includes(".zip"));
    assert.ok(Array.isArray(res.body.steps));
    assert.ok(res.body.steps.length >= 4);
    assert.ok(res.body.archive_command.includes("tar"));
  });
});

describe("POST /api/import/scan-path validation", () => {
  it("rejects missing path", async () => {
    const res = await post("/api/import/scan-path", {});
    assert.equal(res.status, 400);
    assert.equal(res.body.error.code, "INVALID_INPUT");
  });

  it("rejects relative paths", async () => {
    const res = await post("/api/import/scan-path", { path: "./somewhere" });
    assert.equal(res.status, 400);
    assert.equal(res.body.error.code, "INVALID_INPUT");
  });

  it("rejects non-existent paths", async () => {
    const res = await post("/api/import/scan-path", {
      path: "/definitely/does/not/exist/ccam-" + Date.now(),
    });
    assert.equal(res.status, 400);
    assert.equal(res.body.error.code, "PATH_NOT_FOUND");
  });

  it("rejects files (not directories)", async () => {
    const tmp = path.join(os.tmpdir(), `ccam-not-dir-${Date.now()}.txt`);
    fs.writeFileSync(tmp, "hello");
    try {
      const res = await post("/api/import/scan-path", { path: tmp });
      assert.equal(res.status, 400);
      assert.equal(res.body.error.code, "NOT_A_DIRECTORY");
    } finally {
      fs.unlinkSync(tmp);
    }
  });
});

describe("POST /api/import/scan-path happy path", () => {
  it("imports sessions from a custom folder and records token usage", async () => {
    const root = writeFixtureDir();
    try {
      const res = await post("/api/import/scan-path", { path: root });
      assert.equal(res.status, 200);
      assert.ok(res.body.ok);
      // Both sessions should import the first time.
      assert.ok(res.body.imported >= 2);
      assert.equal(res.body.errors, 0);

      const sessA = stmts.getSession.get(SESSION_A);
      const sessB = stmts.getSession.get(SESSION_B);
      assert.ok(sessA, "session A should exist in DB");
      assert.ok(sessB, "session B should exist in DB");

      // Tokens: each fixture has 2 assistant messages with usage.
      const tokA = stmts.getTokensBySession.all(SESSION_A);
      const opus = tokA.find((t) => /opus/.test(t.model));
      assert.ok(opus, "expected opus tokens");
      assert.equal(opus.input_tokens, 200);
      assert.equal(opus.output_tokens, 100);
      assert.equal(opus.cache_read_tokens, 10);
      assert.equal(opus.cache_write_tokens, 20);

      // Cost endpoint should produce a non-zero result after we add a pricing rule.
      const ruleRes = await fetch("/api/pricing", {
        method: "PUT",
        body: {
          model_pattern: "claude-opus-4-7",
          display_name: "Opus 4.7",
          input_per_mtok: 15,
          output_per_mtok: 75,
          cache_read_per_mtok: 1.5,
          cache_write_per_mtok: 18.75,
        },
      });
      assert.equal(ruleRes.status, 200);

      const costRes = await fetch(`/api/pricing/cost/${SESSION_A}`);
      assert.equal(costRes.status, 200);
      assert.ok(costRes.body.total_cost > 0);
    } finally {
      fs.rmSync(root, { recursive: true, force: true });
    }
  });

  it("is idempotent: a second scan does not duplicate tokens", async () => {
    const before = stmts.getTokensBySession.all(SESSION_A);
    const root = writeFixtureDir();
    try {
      const res = await post("/api/import/scan-path", { path: root });
      assert.equal(res.status, 200);
      const after = stmts.getTokensBySession.all(SESSION_A);
      const beforeOpus = before.find((t) => /opus/.test(t.model));
      const afterOpus = after.find((t) => /opus/.test(t.model));
      assert.equal(afterOpus.input_tokens, beforeOpus.input_tokens);
      assert.equal(afterOpus.output_tokens, beforeOpus.output_tokens);
    } finally {
      fs.rmSync(root, { recursive: true, force: true });
    }
  });
});

describe("archive helpers", () => {
  it("isPathInside rejects traversal", () => {
    const root = path.resolve("/tmp/ccam-root");
    assert.equal(archive.isPathInside(root, "/tmp/ccam-root/ok.jsonl"), true);
    assert.equal(archive.isPathInside(root, "/tmp/other/bad.jsonl"), false);
    assert.equal(archive.isPathInside(root, "/tmp/ccam-root/../escape"), false);
  });

  it("safeJoin rejects absolute and traversal entries", () => {
    const root = path.resolve("/tmp/ccam-root");
    assert.equal(archive.safeJoin(root, "/etc/passwd"), path.join(root, "etc/passwd"));
    assert.equal(archive.safeJoin(root, "../escape.txt"), null);
    assert.equal(archive.safeJoin(root, "deep/../../escape"), null);
    assert.ok(archive.safeJoin(root, "good/file.jsonl").startsWith(root));
  });

  it("detectKind handles common extensions", () => {
    assert.equal(archive.detectKind("a.jsonl"), "jsonl");
    assert.equal(archive.detectKind("a.meta.json"), "meta");
    assert.equal(archive.detectKind("a.zip"), "zip");
    assert.equal(archive.detectKind("a.tar"), "tar");
    assert.equal(archive.detectKind("a.tar.gz"), "tgz");
    assert.equal(archive.detectKind("a.tgz"), "tgz");
    assert.equal(archive.detectKind("a.gz"), "gz");
    assert.equal(archive.detectKind("random.bin"), "unknown");
  });

  it("extractGzSingle decompresses plain gz", async () => {
    const dest = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-gz-"));
    const src = path.join(dest, "sample.jsonl.gz");
    fs.writeFileSync(src, zlib.gzipSync(Buffer.from('{"ok":true}\n')));
    try {
      const result = await archive.extractGzSingle(src, dest);
      assert.equal(result.extracted, 1);
      assert.ok(fs.existsSync(path.join(dest, "sample.jsonl")));
    } finally {
      fs.rmSync(dest, { recursive: true, force: true });
    }
  });
});

describe("importFromDirectory directly", () => {
  it("reports progress and never throws on empty dirs", async () => {
    const empty = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-empty-"));
    try {
      const events = [];
      const counters = await importHistory.importFromDirectory({ db, stmts }, empty, {
        onProgress: (p) => events.push(p.phase),
      });
      assert.equal(counters.filesScanned, 0);
      assert.ok(events.includes("complete"));
    } finally {
      fs.rmSync(empty, { recursive: true, force: true });
    }
  });

  it("matches the legacy importer's token totals on the same fixtures", async () => {
    // Clean any tokens from prior tests for a fresh comparison.
    const freshSession = "cccccccc-3333-4333-8333-cccccccccccc";
    const root = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-fresh-"));
    const projDir = path.join(root, "-Users-demo-fresh");
    fs.mkdirSync(projDir, { recursive: true });
    fs.writeFileSync(
      path.join(projDir, `${freshSession}.jsonl`),
      fixtureLines(freshSession, "/Users/demo/fresh", "claude-haiku-4-5", 7, 3)
        .map((o) => JSON.stringify(o))
        .join("\n")
    );

    try {
      await importHistory.importFromDirectory({ db, stmts }, root);
      const tok = stmts.getTokensBySession.all(freshSession);
      const haiku = tok.find((t) => /haiku/.test(t.model));
      assert.ok(haiku);
      assert.equal(haiku.input_tokens, 14); // 7 * 2 messages
      assert.equal(haiku.output_tokens, 6); // 3 * 2 messages
    } finally {
      fs.rmSync(root, { recursive: true, force: true });
    }
  });

  it("snapshots the transcript into the data dir so it survives Claude Code pruning", async () => {
    // Regression: the Conversation tab reads JSONL live from ~/.claude/projects,
    // but Claude Code deletes session files older than cleanupPeriodDays
    // (default 30 days). Import must snapshot the transcript into the
    // dashboard's own data dir so the conversation survives that deletion.
    const prevDataDir = process.env.DASHBOARD_DATA_DIR;
    const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-data-"));
    process.env.DASHBOARD_DATA_DIR = dataDir;
    const src = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-src-"));
    const sessionId = "dddddddd-4444-4444-8444-dddddddddddd";
    fs.writeFileSync(
      path.join(src, `${sessionId}.jsonl`),
      fixtureLines(sessionId, "/Users/demo/snap", "claude-opus-4-8", 5, 3)
        .map((o) => JSON.stringify(o))
        .join("\n")
    );

    try {
      await importHistory.importFromDirectory({ db, stmts }, src);
      const snapshot = path.join(dataDir, "transcripts", `${sessionId}.jsonl`);
      assert.ok(fs.existsSync(snapshot), "transcript should be snapshotted into the data dir");
      assert.ok(
        fs.readFileSync(snapshot, "utf8").includes('"text":"ok"'),
        "snapshot should contain the original conversation"
      );

      // And the read route should resolve it via the snapshot helper.
      const { getSnapshotTranscriptPath } = require("../lib/claude-home");
      assert.equal(getSnapshotTranscriptPath(sessionId), snapshot);
    } finally {
      if (prevDataDir === undefined) delete process.env.DASHBOARD_DATA_DIR;
      else process.env.DASHBOARD_DATA_DIR = prevDataDir;
      fs.rmSync(src, { recursive: true, force: true });
      fs.rmSync(dataDir, { recursive: true, force: true });
    }
  });
});

describe("POST /api/import/rescan", () => {
  it("runs without crashing even when default projects dir is missing", async () => {
    // We can't mutate the real projects dir, but we can assert the endpoint
    // always returns a JSON envelope regardless of whether it found anything.
    const res = await post("/api/import/rescan");
    assert.equal(res.status, 200);
    assert.equal(res.body.ok, true);
    assert.equal(typeof res.body.imported, "number");
    assert.equal(typeof res.body.skipped, "number");
  });
});

// ────────────────────────────────────────────────────────────────────────────
// Hardening tests
// ────────────────────────────────────────────────────────────────────────────

describe("tar path-traversal hardening", () => {
  it("extractTar rejects entries with ../ segments", async () => {
    const tar = require("tar");
    const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-tar-bad-"));
    const stageDir = path.join(tmp, "stage");
    const targetDir = path.join(tmp, "target");
    fs.mkdirSync(stageDir, { recursive: true });
    fs.mkdirSync(targetDir, { recursive: true });
    // Build a tar that tries to write "../escape.jsonl".
    const inner = path.join(stageDir, "legit.jsonl");
    fs.writeFileSync(inner, '{"ok":true}\n');
    const tarPath = path.join(tmp, "evil.tar");
    await tar.c({ file: tarPath, cwd: stageDir, prefix: "../" }, ["legit.jsonl"]);

    try {
      await archive.extractTar(tarPath, targetDir);
      // Anything extracted must remain inside targetDir.
      const walked = [];
      (function walk(d) {
        for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
          const p = path.join(d, ent.name);
          if (ent.isDirectory()) walk(p);
          else walked.push(p);
        }
      })(targetDir);
      for (const p of walked) {
        assert.ok(
          path.resolve(p).startsWith(path.resolve(targetDir) + path.sep) ||
            path.resolve(p) === path.resolve(targetDir),
          `traversal escape detected: ${p}`
        );
      }
      // Nothing should exist one level above targetDir with name escape.jsonl
      assert.equal(fs.existsSync(path.join(tmp, "escape.jsonl")), false);
    } finally {
      fs.rmSync(tmp, { recursive: true, force: true });
    }
  });
});

describe("extraction size cap", () => {
  it("extractGzSingle aborts past MAX_EXTRACT_BYTES", async () => {
    const prev = process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES;
    process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES = "128";
    // Re-require to pick up the lowered limit for this one check.
    delete require.cache[require.resolve("../lib/archive")];
    const localArchive = require("../lib/archive");
    const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-bomb-"));
    const gzPath = path.join(tmp, "bomb.jsonl.gz");
    // 2 KB of zeros compresses to a few bytes — decompressing blows past 128 B.
    fs.writeFileSync(gzPath, zlib.gzipSync(Buffer.alloc(2048, 0)));
    try {
      await assert.rejects(
        () => localArchive.extractGzSingle(gzPath, tmp),
        (err) => err.code === "EXTRACTION_LIMIT_EXCEEDED"
      );
    } finally {
      fs.rmSync(tmp, { recursive: true, force: true });
      if (prev === undefined) delete process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES;
      else process.env.CCAM_IMPORT_MAX_EXTRACT_BYTES = prev;
      // Restore the module with production limits for subsequent tests.
      delete require.cache[require.resolve("../lib/archive")];
      require("../lib/archive");
    }
  });
});

describe("orphan subagent inference", () => {
  it("attaches subagent via Layout 2: <proj>/subagents/<sessionId>/agent.jsonl", async () => {
    const orphanSession = "dddddddd-4444-4444-8444-dddddddddddd";
    // Seed a parent session first.
    const seedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-orphan-seed-"));
    const seedProj = path.join(seedRoot, "project");
    fs.mkdirSync(seedProj, { recursive: true });
    fs.writeFileSync(
      path.join(seedProj, `${orphanSession}.jsonl`),
      fixtureLines(orphanSession, "/Users/demo/orphan", "claude-opus-4-7", 5, 5)
        .map((o) => JSON.stringify(o))
        .join("\n")
    );
    await importHistory.importFromDirectory({ db, stmts }, seedRoot);
    assert.ok(stmts.getSession.get(orphanSession), "parent session must exist before orphan pass");

    // Now create an "orphan" subagent tree in the non-standard layout.
    const orphanRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-orphan-"));
    const orphanLayoutDir = path.join(orphanRoot, "project", "subagents", orphanSession);
    fs.mkdirSync(orphanLayoutDir, { recursive: true });
    const subAgentId = "agent-xyz";
    fs.writeFileSync(
      path.join(orphanLayoutDir, `${subAgentId}.jsonl`),
      [
        { type: "user", timestamp: "2026-04-18T12:00:00.000Z", message: { content: "hi" } },
        {
          type: "assistant",
          timestamp: "2026-04-18T12:00:00.000Z",
          message: {
            model: "claude-opus-4-7",
            content: [{ type: "text", text: "ok" }],
            usage: { input_tokens: 1, output_tokens: 1 },
          },
        },
      ]
        .map((o) => JSON.stringify(o))
        .join("\n")
    );

    try {
      const before = db
        .prepare("SELECT COUNT(*) as c FROM agents WHERE session_id = ?")
        .get(orphanSession).c;
      await importHistory.importFromDirectory({ db, stmts }, orphanRoot);
      const after = db
        .prepare("SELECT COUNT(*) as c FROM agents WHERE session_id = ?")
        .get(orphanSession).c;
      assert.ok(after > before, "orphan subagent should attach under known session");
    } finally {
      fs.rmSync(seedRoot, { recursive: true, force: true });
      fs.rmSync(orphanRoot, { recursive: true, force: true });
    }
  });
});

describe("concurrent scan-path requests", () => {
  it("two concurrent imports of different folders both succeed without clobbering", async () => {
    const sessA = "eeeeeeee-5555-4555-8555-eeeeeeeeeeee";
    const sessB = "ffffffff-6666-4666-8666-ffffffffffff";
    const rootA = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-concurrent-a-"));
    const rootB = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-concurrent-b-"));
    fs.mkdirSync(path.join(rootA, "-Users-demo-a"), { recursive: true });
    fs.mkdirSync(path.join(rootB, "-Users-demo-b"), { recursive: true });
    fs.writeFileSync(
      path.join(rootA, "-Users-demo-a", `${sessA}.jsonl`),
      fixtureLines(sessA, "/Users/demo/a", "claude-opus-4-7", 3, 2)
        .map((o) => JSON.stringify(o))
        .join("\n")
    );
    fs.writeFileSync(
      path.join(rootB, "-Users-demo-b", `${sessB}.jsonl`),
      fixtureLines(sessB, "/Users/demo/b", "claude-sonnet-4-6", 4, 1)
        .map((o) => JSON.stringify(o))
        .join("\n")
    );

    try {
      const [rA, rB] = await Promise.all([
        post("/api/import/scan-path", { path: rootA }),
        post("/api/import/scan-path", { path: rootB }),
      ]);
      assert.equal(rA.status, 200);
      assert.equal(rB.status, 200);
      assert.ok(stmts.getSession.get(sessA), "session A should be imported");
      assert.ok(stmts.getSession.get(sessB), "session B should be imported");
    } finally {
      fs.rmSync(rootA, { recursive: true, force: true });
      fs.rmSync(rootB, { recursive: true, force: true });
    }
  });
});
