/**
 * @file Tests for Node.js project detection and profile scaffolding
 * (server/lib/lane-detect.js): layout precedence, script selection, the
 * shell-identifier validator, scaffold output, and the profile checker.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

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

const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-detect-"));
// Must be set before the first require of anything that transitively loads
// server/lib/secrets.js (lane-detect.js does, for checkProfile) — SECRETS_PATH
// is bound once at that module's first load, and without this a test run on a
// machine with a real ~/.ccam/secrets.env would read that file instead of a
// deterministic, isolated one.
process.env.CCAM_SECRETS_PATH = path.join(SUITE_ROOT, "secrets.env");
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));

const detect = require("../lib/lane-detect");

let repoSeq = 0;
function makeRepo() {
  repoSeq += 1;
  const dir = path.join(SUITE_ROOT, `repo-${repoSeq}`);
  fs.mkdirSync(dir, { recursive: true });
  return dir;
}

function writePkg(dir, scripts) {
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ scripts }, null, 2));
}

describe("detectNode: layout", () => {
  it("returns null when nothing is detected", () => {
    const repo = makeRepo();
    assert.equal(detect.detectNode(repo), null);
  });

  it("detects single-service from a root package.json", () => {
    const repo = makeRepo();
    writePkg(repo, { start: "node index.js" });
    const facts = detect.detectNode(repo);
    assert.equal(facts.layout, "single-service");
    assert.equal(facts.services.app.script, "start");
  });

  it("prefers monorepo when both backend/ and frontend/ package.json exist, even with a root one too", () => {
    const repo = makeRepo();
    writePkg(repo, { start: "node root.js" });
    writePkg(path.join(repo, "backend"), { start: "node server.js" });
    writePkg(path.join(repo, "frontend"), { dev: "vite" });
    const facts = detect.detectNode(repo);
    assert.equal(facts.layout, "monorepo");
    assert.equal(facts.services.backend.script, "start");
    assert.equal(facts.services.frontend.script, "dev");
  });

  it("does not detect a backend/ with no matching frontend/", () => {
    const repo = makeRepo();
    writePkg(path.join(repo, "backend"), { start: "node server.js" });
    assert.equal(detect.detectNode(repo), null);
  });
});

describe("detectNode: script selection", () => {
  it("backend prefers start over dev", () => {
    const repo = makeRepo();
    writePkg(path.join(repo, "backend"), { start: "node a.js", dev: "node b.js" });
    writePkg(path.join(repo, "frontend"), { dev: "vite" });
    assert.equal(detect.detectNode(repo).services.backend.script, "start");
  });

  it("frontend prefers preview over dev", () => {
    const repo = makeRepo();
    writePkg(path.join(repo, "backend"), { start: "node a.js" });
    writePkg(path.join(repo, "frontend"), { dev: "vite", preview: "vite preview" });
    assert.equal(detect.detectNode(repo).services.frontend.script, "preview");
  });

  it("falls back to null (not a guess) when no candidate script exists", () => {
    const repo = makeRepo();
    writePkg(path.join(repo, "backend"), { test: "jest" });
    writePkg(path.join(repo, "frontend"), { build: "vite build" });
    const facts = detect.detectNode(repo);
    assert.equal(facts.services.backend.script, null);
    assert.equal(facts.services.frontend.script, null);
  });
});

describe("IDENTIFIER_RE", () => {
  it("accepts plain identifiers", () => {
    for (const ok of ["start", "dev:watch", "build.prod", "api-server"]) {
      assert.ok(detect.IDENTIFIER_RE.test(ok), ok);
    }
  });

  it("rejects shell metacharacters", () => {
    for (const bad of ["start; rm -rf /", "$(id)", "a b", "a|b", "a`b`"]) {
      assert.ok(!detect.IDENTIFIER_RE.test(bad), bad);
    }
  });
});

function writeCompose(repo, servicesYaml) {
  fs.writeFileSync(path.join(repo, "docker-compose.yml"), `services:\n${servicesYaml}\n`);
}

describe("detectNode: database + Redis + env wiring", () => {
  it("detects a postgres service in docker-compose.yml", () => {
    const repo = makeRepo();
    writePkg(path.join(repo, "backend"), { start: "node server.js" });
    writePkg(path.join(repo, "frontend"), { dev: "vite" });
    writeCompose(repo, "  postgres:\n    image: postgres:16\n");
    const facts = detect.detectNode(repo);
    assert.equal(facts.database.dbKind, "postgres");
    assert.equal(facts.database.dbService, "postgres");
    assert.match(facts.database.dbPrefix, /_l$/);
    assert.equal(facts.redis, false);
  });

  it("detects redis independently of postgres", () => {
    const repo = makeRepo();
    writePkg(path.join(repo, "backend"), { start: "node server.js" });
    writePkg(path.join(repo, "frontend"), { dev: "vite" });
    writeCompose(repo, "  cache_redis:\n    image: redis:7\n");
    const facts = detect.detectNode(repo);
    assert.equal(facts.database, null);
    assert.equal(facts.redis, true);
  });

  it("finds no database when there is no docker-compose.yml", () => {
    const repo = makeRepo();
    writePkg(repo, { start: "node index.js" });
    const facts = detect.detectNode(repo);
    assert.equal(facts.database, null);
    assert.equal(facts.redis, false);
    assert.equal(facts.env, null);
  });

  it("wires ENV_REWRITE when a database is found and backend/.env.example exists", () => {
    const repo = makeRepo();
    writePkg(path.join(repo, "backend"), { start: "node server.js" });
    writePkg(path.join(repo, "frontend"), { dev: "vite" });
    writeCompose(repo, "  postgres:\n    image: postgres:16\n  redis:\n    image: redis:7\n");
    fs.writeFileSync(path.join(repo, "backend", ".env.example"), "FOO=bar\n");
    const facts = detect.detectNode(repo);
    assert.equal(facts.env.file, "backend/.env");
    assert.equal(facts.env.source, "backend/.env.example");
    assert.equal(facts.env.fromExample, true);
    assert.deepEqual(facts.env.rewrite, ["DATABASE_URL", "REDIS_URL"]);
  });

  it("leaves env null when a database is found but there is no .env or .env.example", () => {
    const repo = makeRepo();
    writePkg(repo, { start: "node index.js" });
    writeCompose(repo, "  postgres:\n    image: postgres:16\n");
    assert.equal(detect.detectNode(repo).env, null);
  });

  it("rejects a docker-compose service name that fails the shell-identifier check", () => {
    const repo = makeRepo();
    writePkg(repo, { start: "node index.js" });
    writeCompose(repo, '  "postgres; rm -rf /":\n    image: postgres:16\n');
    assert.equal(detect.detectNode(repo).database, null);
  });
});

describe("scaffoldProfile", () => {
  it("writes a single-service profile with no database: bootstrap/boot/health only, zero TODOs", () => {
    const repo = makeRepo();
    writePkg(repo, { start: "node index.js" });
    const facts = detect.detectNode(repo);

    const result = detect.scaffoldProfile(repo, facts);

    const profileDir = path.join(repo, ".ccam", "profile");
    const env = fs.readFileSync(path.join(profileDir, "profile.env"), "utf8");
    assert.match(env, /PORTS="app"/);
    assert.match(env, /PORT_BASE_app=3000/);
    assert.doesNotMatch(env, /DB_PREFIX/);
    assert.doesNotMatch(env, /TODO/);

    for (const hook of ["bootstrap.sh", "boot.sh", "health.sh"]) {
      const hookPath = path.join(profileDir, "hooks", hook);
      assert.ok(fs.existsSync(hookPath), hook);
      assert.ok(fs.statSync(hookPath).mode & 0o111, `${hook} must be executable`);
    }
    assert.match(
      fs.readFileSync(path.join(profileDir, "hooks", "boot.sh"), "utf8"),
      /npm run start/
    );
    assert.equal(result.todos.length, 0);
  });

  it("writes a monorepo profile with a database: DB_PREFIX, ENV_REWRITE, copied db hooks, TODO migrate/seed", () => {
    const repo = makeRepo();
    writePkg(path.join(repo, "backend"), { start: "node server.js" });
    writePkg(path.join(repo, "frontend"), { dev: "vite" }); // no preview -> exercises dev fallback
    writeCompose(repo, "  postgres:\n    image: postgres:16\n  redis:\n    image: redis:7\n");
    fs.writeFileSync(path.join(repo, "backend", ".env.example"), "FOO=bar\n");
    const facts = detect.detectNode(repo);

    const result = detect.scaffoldProfile(repo, facts);

    const profileDir = path.join(repo, ".ccam", "profile");
    const env = fs.readFileSync(path.join(profileDir, "profile.env"), "utf8");
    assert.match(env, /DB_PREFIX=/);
    assert.match(env, /REDIS=1/);
    assert.match(env, /ENV_FILES="backend\/\.env"/);
    assert.match(env, /ENV_REWRITE="DATABASE_URL REDIS_URL"/);

    for (const hook of ["db-create.sh", "db-drop.sh", "migrate.sh", "seed.sh"]) {
      assert.ok(fs.existsSync(path.join(profileDir, "hooks", hook)), hook);
    }
    assert.match(fs.readFileSync(path.join(profileDir, "hooks", "migrate.sh"), "utf8"), /exit 0/);
    assert.match(
      fs.readFileSync(path.join(profileDir, "hooks", "boot.sh"), "utf8"),
      /npm run dev/ // frontend fallback, no preview script
    );
    assert.ok(result.todos.length >= 2, "migrate and seed are both TODO");
  });

  it("writes a TODO boot line (never a guess) when no start/dev script was found", () => {
    const repo = makeRepo();
    writePkg(repo, { test: "jest" }); // no start, no dev
    const facts = {
      layout: "single-service",
      services: { app: { dir: ".", script: null } },
      database: null,
      redis: false,
      env: null,
    };
    const result = detect.scaffoldProfile(repo, facts);
    const boot = fs.readFileSync(path.join(repo, ".ccam", "profile", "hooks", "boot.sh"), "utf8");
    assert.match(boot, /# TODO: no start\/dev script found/);
    assert.equal(result.todos.length, 1);
  });

  it("refuses to overwrite an existing profile without force", () => {
    const repo = makeRepo();
    writePkg(repo, { start: "node index.js" });
    const facts = detect.detectNode(repo);
    detect.scaffoldProfile(repo, facts);
    assert.throws(
      () => detect.scaffoldProfile(repo, facts),
      (err) => err.code === "EPROFILEEXISTS"
    );
  });

  it("overwrites when force is true", () => {
    const repo = makeRepo();
    writePkg(repo, { start: "node index.js" });
    const facts = detect.detectNode(repo);
    detect.scaffoldProfile(repo, facts);
    assert.doesNotThrow(() => detect.scaffoldProfile(repo, facts, { force: true }));
  });
});

describe("checkProfile", () => {
  it("passes a freshly-scaffolded no-database profile with a real script", async () => {
    const repo = makeRepo();
    writePkg(repo, { start: "node index.js" });
    detect.scaffoldProfile(repo, detect.detectNode(repo));
    const result = await detect.checkProfile(repo);
    assert.deepEqual(result.errors, []);
    assert.equal(result.ok, true);
  });

  it("fails on a database profile with unresolved migrate/seed TODOs", async () => {
    const repo = makeRepo();
    writePkg(path.join(repo, "backend"), { start: "node server.js" });
    writePkg(path.join(repo, "frontend"), { dev: "vite" });
    writeCompose(repo, "  postgres:\n    image: postgres:16\n");
    detect.scaffoldProfile(repo, detect.detectNode(repo));
    const result = await detect.checkProfile(repo);
    assert.equal(result.ok, false);
    assert.ok(result.errors.some((e) => /TODO/.test(e)));
  });

  it("fails when a declared port is already in use", async () => {
    const net = require("node:net");
    const repo = makeRepo();
    writePkg(repo, { start: "node index.js" });
    detect.scaffoldProfile(repo, detect.detectNode(repo));
    const server = net.createServer(() => {});
    await new Promise((resolve, reject) => {
      server.once("error", reject);
      server.listen(3000, "127.0.0.1", resolve);
    });
    try {
      const result = await detect.checkProfile(repo);
      assert.equal(result.ok, false);
      assert.ok(result.errors.some((e) => /3000/.test(e)));
    } finally {
      server.close();
    }
  });

  it("fails when a referenced hook is missing", async () => {
    const repo = makeRepo();
    writePkg(repo, { start: "node index.js" });
    detect.scaffoldProfile(repo, detect.detectNode(repo));
    fs.rmSync(path.join(repo, ".ccam", "profile", "hooks", "health.sh"));
    const result = await detect.checkProfile(repo);
    assert.equal(result.ok, false);
    assert.ok(result.errors.some((e) => /health/.test(e)));
  });

  it("warns (does not fail) when secrets.env is missing for a database profile", async () => {
    const repo = makeRepo();
    writePkg(repo, { start: "node index.js" });
    writeCompose(repo, "  postgres:\n    image: postgres:16\n");
    detect.scaffoldProfile(repo, detect.detectNode(repo));
    // Remove the migrate/seed TODO lines so only the secrets warning is left to check.
    const hooksDir = path.join(repo, ".ccam", "profile", "hooks");
    fs.writeFileSync(path.join(hooksDir, "migrate.sh"), "#!/usr/bin/env bash\nexit 0\n");
    fs.writeFileSync(path.join(hooksDir, "seed.sh"), "#!/usr/bin/env bash\nexit 0\n");
    const result = await detect.checkProfile(repo);
    assert.ok(result.warnings.some((w) => /secrets\.env/.test(w)));
  });

  it("fails when a hook file becomes unreadable (permission or race condition)", async () => {
    const repo = makeRepo();
    writePkg(repo, { start: "node index.js" });
    detect.scaffoldProfile(repo, detect.detectNode(repo));
    const hookPath = path.join(repo, ".ccam", "profile", "hooks", "health.sh");
    fs.chmodSync(hookPath, 0o000);
    try {
      const result = await detect.checkProfile(repo);
      assert.equal(result.ok, false);
      assert.ok(result.errors.some((e) => /not readable/.test(e)));
    } finally {
      fs.chmodSync(hookPath, 0o755);
    }
  });
});

describe("end to end: init -> check -> real boot", () => {
  it("a single-service, no-database repo boots for real after init", async () => {
    const lanesLib = require("../lib/lanes");
    const runtime = require("../lib/lane-runtime");

    const repo = makeRepo();
    // A plain Node script reading process.env.PORT — the exact convention
    // bootLine's `env PORT="$..." npm run <script>` targets.
    fs.writeFileSync(
      path.join(repo, "package.json"),
      JSON.stringify({
        scripts: {
          start:
            "node -e \"require('http').createServer((_,res)=>res.end('ok')).listen(process.env.PORT)\"",
        },
      })
    );
    const facts = detect.detectNode(repo);
    detect.scaffoldProfile(repo, facts);
    const checked = await detect.checkProfile(repo);
    assert.equal(checked.ok, true, JSON.stringify(checked.errors));

    const lane = lanesLib.createLane({ title: "e2e", cwd: repo, kind: "managed" });
    try {
      const runtimeFacts = await runtime.upLane(lanesLib.getLane(lane.id));
      assert.equal(runtimeFacts.up, true);
      assert.equal(runtimeFacts.healthy, true);
    } finally {
      await runtime.downLane(lanesLib.getLane(lane.id));
    }
  });

  it("a monorepo with a database scaffolds correctly but check fails until migrate/seed are finished", async () => {
    const repo = makeRepo();
    writePkg(path.join(repo, "backend"), { start: "node server.js" });
    writePkg(path.join(repo, "frontend"), { dev: "vite" });
    writeCompose(repo, "  postgres:\n    image: postgres:16\n  redis:\n    image: redis:7\n");
    fs.writeFileSync(path.join(repo, "backend", ".env.example"), "SOMETHING=1\n");

    const facts = detect.detectNode(repo);
    const scaffolded = detect.scaffoldProfile(repo, facts);
    assert.ok(scaffolded.todos.includes("hooks/migrate.sh"));
    assert.ok(scaffolded.todos.includes("hooks/seed.sh"));

    const checked = await detect.checkProfile(repo);
    assert.equal(checked.ok, false);
    assert.ok(checked.errors.some((e) => /migrate\.sh/.test(e)));
    assert.ok(checked.errors.some((e) => /seed\.sh/.test(e)));
  });
});
