/**
 * @file Tests the pure pipeline-rule matcher, including defensive input
 * flattening, invalid-template handling, and later-stage precedence.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const test = require("node:test");
const assert = require("node:assert/strict");
const { compileRules, detect, flattenInput } = require("../lib/stage-detect");
const { getPipeline } = require("../lib/pipelines");

const pipeline = {
  nodes: [
    { id: "plan", detect: [{ tool: "Skill", match: "brainstorming" }] },
    { id: "implement", detect: [{ tool: "Edit" }] },
    { id: "tests", detect: [{ tool: "Bash", match: "npm run test:server" }] },
    { id: "ship", detect: [{ tool: "Bash", match: "git push" }] },
  ],
};

test("detects declared rules and reports a readable signal", () => {
  assert.deepEqual(
    detect(pipeline, { tool_name: "Bash", tool_input: { command: "npm run test:server" } }),
    {
      nodeId: "tests",
      signal: "`npm run test:server`",
    }
  );
  assert.deepEqual(
    detect(pipeline, { tool_name: "Edit", tool_input: { file_path: "server/lib/app.js" } }),
    {
      nodeId: "implement",
      signal: "`server/lib/app.js`",
    }
  );
  assert.deepEqual(
    detect(pipeline, { tool_name: "Skill", tool_input: { skill: "brainstorming" } }),
    {
      nodeId: "plan",
      signal: "`brainstorming`",
    }
  );
  assert.deepEqual(
    detect(pipeline, { tool_name: "Bash", tool_input: { command: "git push origin main" } }),
    {
      nodeId: "ship",
      signal: "`git push origin main`",
    }
  );
});

test("returns null for unmatched or malformed events", () => {
  assert.equal(
    detect(pipeline, { tool_name: "Read", tool_input: { file_path: "README.md" } }),
    null
  );
  assert.equal(detect(pipeline, {}), null);
  assert.equal(detect(null, { tool_name: "Edit", tool_input: {} }), null);
});

test("skips invalid regular expressions while retaining valid rules", () => {
  const malformed = {
    nodes: [
      { id: "broken", detect: [{ tool: "Bash", match: "[" }] },
      { id: "tests", detect: [{ tool: "Bash", match: "npm run test:server" }] },
    ],
  };

  assert.deepEqual(compileRules(malformed), [
    { nodeId: "broken", rules: [] },
    { nodeId: "tests", rules: [{ tool: "Bash", regex: /npm run test:server/ }] },
  ]);
  assert.deepEqual(
    detect(malformed, { tool_name: "Bash", tool_input: { command: "npm run test:server" } }),
    {
      nodeId: "tests",
      signal: "`npm run test:server`",
    }
  );
});

test("flattenInput handles awkward tool-input shapes without traversing deeply", () => {
  assert.equal(flattenInput(null), "");
  assert.equal(flattenInput("git push"), "git push");
  assert.equal(flattenInput(["git", "push", { command: "origin main" }]), "git push origin main");
  assert.equal(
    flattenInput({
      command: "npm test",
      file_path: "server/app.js",
      count: 1,
      nested: { prompt: "ignored" },
    }),
    "npm test server/app.js"
  );
  assert.equal(flattenInput({ nested: { command: "never recurse" } }), "");
});

test("caps a long signal so it never inflates a DB column or a title attribute", () => {
  const longCommand = `git push ${"x".repeat(4990)}`;
  const result = detect(pipeline, { tool_name: "Bash", tool_input: { command: longCommand } });
  assert.ok(result.signal.length < longCommand.length);
  assert.ok(result.signal.startsWith("`git push xxxx"));
  assert.ok(result.signal.endsWith("…`"));
});

test("flattenInput keeps only fields that identify what a tool did, dropping Edit's code payload", () => {
  assert.equal(
    flattenInput({
      file_path: "server/lib/app.js",
      old_string: "function old() { return 1; }",
      new_string: "function updated() { return 2; }",
    }),
    "server/lib/app.js"
  );

  const signal = detect(pipeline, {
    tool_name: "Edit",
    tool_input: {
      file_path: "server/lib/app.js",
      old_string: "function old() { return 1; }",
      new_string: "function updated() { return 2; }",
    },
  }).signal;
  assert.ok(signal.includes("server/lib/app.js"));
  assert.ok(!signal.includes("function old"));
  assert.ok(!signal.includes("function updated"));
});

test("compileRules stays total on a null pipeline, non-array nodes, and a non-array detect", () => {
  assert.deepEqual(compileRules(null), []);
  assert.deepEqual(compileRules({ nodes: "not-an-array" }), []);
  assert.deepEqual(compileRules({ nodes: [{ id: "weird", detect: "not-an-array" }] }), [
    { nodeId: "weird", rules: [] },
  ]);
});

test("detects stage through the real default pipeline template, end-to-end via getPipeline", () => {
  const pipeline = getPipeline("default");
  assert.equal(
    detect(pipeline, { tool_name: "Bash", tool_input: { command: "npm run test:server" } }).nodeId,
    "tests"
  );
  assert.equal(
    detect(pipeline, { tool_name: "Edit", tool_input: { file_path: "server/lib/app.js" } }).nodeId,
    "implement"
  );
});

// Every rule default.json ships, asserted through the REAL loader. A rule
// pinned only against a hand-written fixture pipeline can ship inert — that is
// how both the dropped-`detect` loader bug and the shadowed `plan` Write rule
// got through review.
test("every shipped default.json rule fires end-to-end via getPipeline", () => {
  const pipeline = getPipeline("default");
  const cases = [
    ["plan", "Skill", { skill: "brainstorming" }],
    ["plan", "Skill", { skill: "writing-plans" }],
    ["implement", "Edit", { file_path: "server/lib/app.js" }],
    ["implement", "Write", { file_path: "server/lib/app.js" }],
    ["tests", "Bash", { command: "npm run test:server" }],
    ["tests", "Bash", { command: "pytest -q" }],
    ["review", "Skill", { skill: "requesting-code-review" }],
    ["review", "Bash", { command: "gh pr diff 12" }],
    ["ship", "Bash", { command: "git push origin HEAD" }],
    ["ship", "Bash", { command: "gh pr create --fill" }],
  ];
  for (const [nodeId, tool_name, tool_input] of cases) {
    const got = detect(pipeline, { tool_name, tool_input });
    assert.equal(got && got.nodeId, nodeId, `${tool_name} ${JSON.stringify(tool_input)}`);
  }
});

test("a plan document written under docs/ reports plan, code written elsewhere reports implement", () => {
  const pipeline = getPipeline("default");
  // `implement`'s Write rule is a LATER node than `plan`'s and detect() takes
  // the last match, so it must exclude docs/ paths or plan's rule can never fire.
  assert.equal(
    detect(pipeline, {
      tool_name: "Write",
      tool_input: { file_path: "docs/superpowers/plans/foo.md" },
    }).nodeId,
    "plan"
  );
  assert.equal(
    detect(pipeline, {
      tool_name: "Write",
      tool_input: { file_path: "/home/me/repo/docs/superpowers/plans/foo.md" },
    }).nodeId,
    "plan"
  );
  assert.equal(
    detect(pipeline, { tool_name: "Write", tool_input: { file_path: "server/lib/x.js" } }).nodeId,
    "implement"
  );
  // A docs/ write that is not a plan document matches neither rule.
  assert.equal(
    detect(pipeline, { tool_name: "Write", tool_input: { file_path: "docs/README.md" } }),
    null
  );
});

test("loadAll preserves a node's detect rules, and tolerates a non-array detect without throwing", () => {
  const fs = require("node:fs");
  const os = require("node:os");
  const path = require("node:path");

  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pipelines-detect-"));
  fs.writeFileSync(
    path.join(dir, "custom.json"),
    JSON.stringify({
      id: "custom-detect-test",
      nodes: [
        { id: "a", detect: [{ tool: "Bash", match: "foo" }] },
        { id: "b", detect: "not-an-array" },
      ],
    })
  );

  const { getPipeline, reload } = require("../lib/pipelines");
  const prevDir = process.env.DASHBOARD_PIPELINES_DIR;
  process.env.DASHBOARD_PIPELINES_DIR = dir;
  reload();
  try {
    const p = getPipeline("custom-detect-test");
    assert.deepEqual(p.nodes[0].detect, [{ tool: "Bash", match: "foo" }]);
    assert.deepEqual(p.nodes[1].detect, []);
  } finally {
    if (prevDir === undefined) delete process.env.DASHBOARD_PIPELINES_DIR;
    else process.env.DASHBOARD_PIPELINES_DIR = prevDir;
    reload();
  }
});

test("the last matching node wins", () => {
  const overlapping = {
    nodes: [
      { id: "implement", detect: [{ tool: "Bash", match: "git" }] },
      { id: "ship", detect: [{ tool: "Bash", match: "git push" }] },
    ],
  };

  assert.deepEqual(
    detect(overlapping, { tool_name: "Bash", tool_input: { command: "git push" } }),
    {
      nodeId: "ship",
      signal: "`git push`",
    }
  );
});

test.describe("signal reports the matched span", () => {
  const pipeline = getPipeline("default");

  test.it("keeps the matched command and drops a long unrelated prefix", () => {
    const long = "cd /home/very/long/path/that/goes/on/forever && npm run test:server 2>&1";
    const r = detect(pipeline, { tool_name: "Bash", tool_input: { command: long } });
    assert.equal(r.nodeId, "tests");
    assert.ok(r.signal.includes("npm run test:server"), r.signal);
    assert.ok(!r.signal.includes("/home/very/long/path/that/goes/on/forever"), r.signal);
  });

  test.it("marks both ends it trimmed with an ellipsis", () => {
    const long = `${"a".repeat(80)} npm test ${"b".repeat(80)}`;
    const r = detect(pipeline, { tool_name: "Bash", tool_input: { command: long } });
    assert.ok(r.signal.startsWith("`…"), r.signal);
    assert.ok(r.signal.endsWith("…`"), r.signal);
  });

  test.it("keeps a match at the very start without a leading ellipsis", () => {
    const r = detect(pipeline, { tool_name: "Bash", tool_input: { command: "npm test" } });
    assert.equal(r.signal, "`npm test`");
  });

  test.it("falls back to the flattened input for a rule with no match pattern", () => {
    const noMatch = {
      id: "custom",
      nodes: [{ id: "implement", label: "implement", aliases: [], detect: [{ tool: "Edit" }] }],
    };
    const r = detect(noMatch, { tool_name: "Edit", tool_input: { file_path: "server/lib/x.js" } });
    assert.equal(r.signal, "`server/lib/x.js`");
  });

  test.it("still caps the fallback when a no-match rule sees a huge input", () => {
    const noMatch = {
      id: "custom",
      nodes: [{ id: "implement", label: "implement", aliases: [], detect: [{ tool: "Edit" }] }],
    };
    const r = detect(noMatch, { tool_name: "Edit", tool_input: { file_path: "x".repeat(400) } });
    // 120 chars + the ellipsis + the two wrapping backticks, minus nothing.
    assert.equal(r.signal.length, 123);
    assert.ok(r.signal.endsWith("\u2026`"), r.signal);
  });

  test.it("still returns null for a tool no rule mentions", () => {
    assert.equal(detect(pipeline, { tool_name: "Read", tool_input: { file_path: "a.js" } }), null);
  });
});

test.describe("rule precision against real command shapes", () => {
  const pipeline = getPipeline("default");

  test.it("does not call reading a diff a code review", () => {
    // Measured on a real session: `git diff --stat openapi.yaml` was run to
    // verify a generated file, and it pinned the lane at `review` — 4 such
    // events outranked 81 test runs and 30 edits, because detect() takes the
    // LAST matching node and recordDetection is forward-only. Reading a diff
    // is what you do all day; it carries no stage information.
    const r = detect(pipeline, {
      tool_name: "Bash",
      tool_input: { command: "git diff --stat openapi.yaml | tail -3" },
    });
    assert.notEqual(r && r.nodeId, "review");
  });

  test.it("still treats an explicit code-review skill as review", () => {
    const r = detect(pipeline, { tool_name: "Skill", tool_input: { skill: "code-review" } });
    assert.equal(r.nodeId, "review");
  });

  test.it("still treats a PR diff as review", () => {
    const r = detect(pipeline, {
      tool_name: "Bash",
      tool_input: { command: "gh pr diff 42" },
    });
    assert.equal(r.nodeId, "review");
  });

  test.it("recognises a push that carries git flags before the subcommand", () => {
    // The real push in this repo is
    //   GIT_TERMINAL_PROMPT=0 git -c credential.helper=... push --force-with-lease
    // and `git push` as an adjacent pair never matched it, so six real pushes
    // produced no `ship` signal at all.
    const r = detect(pipeline, {
      tool_name: "Bash",
      tool_input: {
        command:
          "GIT_TERMINAL_PROMPT=0 git -c credential.helper=x push --force-with-lease origin main",
      },
    });
    assert.equal(r.nodeId, "ship");
  });

  test.it("does not read an unrelated command as a push", () => {
    for (const command of ["git add -A && git commit -q -m x", "npm run push-docs"]) {
      const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
      assert.notEqual(r && r.nodeId, "ship", command);
    }
  });
});

test.describe("ship detection against this repo's actual push command", () => {
  const pipeline = getPipeline("default");
  // Verbatim from this repo's real push: the credential helper embeds `;` and
  // `|`-free shell between `git` and `push`, so a same-segment pattern like
  // `git[^;&|]*push` never matches it. Six real pushes produced no signal.
  const REAL_PUSH =
    "cd /repo && git add -A && git commit -q --amend --no-edit && " +
    "GIT_TERMINAL_PROMPT=0 git -c credential.helper='!f() { echo username=token; echo \"password=$TK\"; }; f' push --force-with-lease origin main";

  test.it("detects ship from the real push command", () => {
    const r = detect(pipeline, { tool_name: "Bash", tool_input: { command: REAL_PUSH } });
    assert.equal(r && r.nodeId, "ship");
  });

  test.it("does not read a run-script named push-something as a push", () => {
    const r = detect(pipeline, {
      tool_name: "Bash",
      tool_input: { command: "git add -A && npm run push-docs" },
    });
    assert.notEqual(r && r.nodeId, "ship");
  });

  test.it("does not read a bare non-git push as a push", () => {
    const r = detect(pipeline, {
      tool_name: "Bash",
      tool_input: { command: "docker push myimage:latest" },
    });
    assert.notEqual(r && r.nodeId, "ship");
  });
});

test.describe("ship must match a git push INVOCATION, not the word push", () => {
  const pipeline = getPipeline("default");

  // An unbounded `git … push` pattern was tried and it mis-fired within
  // minutes on a live lane: `git log --oneline -1` in a command that also
  // mentioned "push" inside a quoted string pinned the lane at `ship`. Because
  // ship is near the end of the pipeline and detection is forward-only, that
  // one over-read stuck — the same failure `git diff` caused for `review`.
  test.it("ignores the word push when git is doing something else", () => {
    const cases = [
      'git log --oneline -1 && echo "probe: push thật"',
      "git status && grep push file.txt",
      'git commit -m "do not push this yet"',
      "git log --oneline | head -3 # remember to push later",
    ];
    for (const command of cases) {
      const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
      assert.notEqual(r && r.nodeId, "ship", command);
    }
  });

  test.it("still matches a real push, including one behind git -c flags", () => {
    const cases = [
      "git push origin main",
      "git push --force-with-lease",
      "GIT_TERMINAL_PROMPT=0 git -c credential.helper='!f() { echo x; }; f' push --force-with-lease origin main",
      "cd /repo && git -c core.hooksPath=/dev/null push origin main",
    ];
    for (const command of cases) {
      const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
      assert.equal(r && r.nodeId, "ship", command);
    }
  });
});

test.describe("tests rule covers the runners this repo actually uses", () => {
  const pipeline = getPipeline("default");

  test.it("detects node's own test runner invoked directly", () => {
    // package.json's test:server IS `node --test server/__tests__/*.test.js`,
    // and running that command directly (rather than through npm) is the
    // common case while iterating on one file. It matched nothing.
    for (const command of [
      "node --test server/__tests__/stage-detect.test.js",
      "node --test --test-name-pattern='detection' server/__tests__/lanes-api.test.js",
    ]) {
      const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
      assert.equal(r && r.nodeId, "tests", command);
    }
  });

  test.it("still detects the runners it already knew", () => {
    for (const command of ["npm run test:server", "npm test", "cd client && npx vitest run src"]) {
      const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
      assert.equal(r && r.nodeId, "tests", command);
    }
  });

  test.it("does not read an unrelated node invocation as a test run", () => {
    for (const command of [
      "node scripts/generate-openapi-yaml.js",
      "node -e \"console.log('hello')\"",
      "node bin/ccam.js start",
    ]) {
      const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
      assert.notEqual(r && r.nodeId, "tests", command);
    }
  });
});

test.describe("ship-feature pipeline template", () => {
  test.it("loads without throwing and has one node per skill stage", () => {
    const { reload } = require("../lib/pipelines");
    reload();
    const pipeline = getPipeline("ship-feature");
    const ids = pipeline.nodes.map((n) => n.id);
    assert.deepEqual(ids, [
      "intake",
      "plan",
      "implementing",
      "gates",
      "e2e-feature",
      "e2e-feature-passed",
      "review",
      "qc-plan",
      "qc",
      "gate",
      "publishing",
      "pr-open",
      "reported",
      "watching-pr",
      "merged",
      "done",
    ]);
  });

  test.it("every node id is unique", () => {
    const { reload } = require("../lib/pipelines");
    reload();
    const pipeline = getPipeline("ship-feature");
    const ids = pipeline.nodes.map((n) => n.id);
    assert.deepEqual(ids, [...new Set(ids)]);
  });

  test.it("no alias collides with another node's id or with a second node's alias", () => {
    // phaseIdx() takes the FIRST node whose id or alias matches, so a duplicate
    // silently resolves a declaration onto the wrong node — the failure mode
    // aliases are supposed to prevent.
    const { reload } = require("../lib/pipelines");
    reload();
    const pipeline = getPipeline("ship-feature");
    const seen = new Map();
    for (const n of pipeline.nodes) {
      for (const name of [n.id, ...n.aliases]) {
        const key = name.toLowerCase();
        assert.equal(
          seen.has(key),
          false,
          `"${name}" claimed by both ${seen.get(key)} and ${n.id}`
        );
        seen.set(key, n.id);
      }
    }
  });

  // Same discipline as default.json's end-to-end rule sweep: a rule pinned only
  // against a fixture can ship inert, and these fire on a live lane's hooks.
  test.it("every shipped ship-feature.json rule fires end-to-end via getPipeline", () => {
    const { reload } = require("../lib/pipelines");
    reload();
    const pipeline = getPipeline("ship-feature");
    const cases = [
      ["intake", "Skill", { skill: "superpowers:brainstorming" }],
      ["intake", "Bash", { command: "ccam feature activate lane-stage-detect" }],
      ["plan", "Skill", { skill: "superpowers:writing-plans" }],
      ["plan", "Write", { file_path: "docs/superpowers/specs/lane-foo.md" }],
      ["implementing", "Skill", { skill: "superpowers:test-driven-development" }],
      ["implementing", "Skill", { skill: "superpowers:systematic-debugging" }],
      ["implementing", "Edit", { file_path: "server/lib/app.js" }],
      ["implementing", "Write", { file_path: "server/lib/app.js" }],
      ["gates", "Bash", { command: "ccam lanes hook ci-gate" }],
      ["gates", "Bash", { command: "ccam lanes sync-base --check feat/foo" }],
      ["e2e-feature", "Bash", { command: "ccam lanes up --qc" }],
      ["e2e-feature", "Bash", { command: "ccam lanes hook e2e" }],
      ["review", "Skill", { skill: "code-review" }],
      ["review", "Skill", { skill: "superpowers:requesting-code-review" }],
      ["qc", "Agent", { subagent_type: "qc-local" }],
      ["qc", "Bash", { command: "ccam lanes proof-link" }],
      ["gate", "Agent", { subagent_type: "senior-gate-reviewer" }],
      ["gate", "Skill", { skill: "superpowers:verification-before-completion" }],
      ["publishing", "Skill", { skill: "superpowers:finishing-a-development-branch" }],
      ["publishing", "Bash", { command: "git push -u origin feat/foo" }],
      ["pr-open", "Bash", { command: "gh pr create --base development --fill" }],
      ["watching-pr", "Bash", { command: "gh pr view https://x/pull/1 --json state" }],
    ];
    for (const [nodeId, tool_name, tool_input] of cases) {
      const got = detect(pipeline, { tool_name, tool_input });
      assert.equal(got && got.nodeId, nodeId, `${tool_name} ${JSON.stringify(tool_input)}`);
    }
  });

  test.it("every sub-state the skill declares resolves onto its node", () => {
    // These names exist so a skill can say WHY a lane sits on a node without
    // the map growing a node per reason (Shipyard's PHASES shape: ~35 stage
    // names over 13 nodes). An unresolvable one is worse than no alias: it
    // records, renders nowhere, and only warns.
    const { reload, phaseIdx } = require("../lib/pipelines");
    reload();
    const pipeline = getPipeline("ship-feature");
    const subStates = {
      bootstrapping: "intake",
      "migration-collision": "gates",
      "sync-conflict": "gates",
      booting: "e2e-feature",
      "e2e-scoped": "e2e-feature",
      live: "e2e-feature",
      "gate-blocked": "gate",
      "push-conflict": "pr-open",
      "push-revalidate": "pr-open",
      "pr-comment-fix": "watching-pr",
    };
    for (const [declared, nodeId] of Object.entries(subStates)) {
      assert.equal(
        phaseIdx(pipeline, declared),
        pipeline.nodes.findIndex((n) => n.id === nodeId),
        `${declared} must resolve to ${nodeId}`
      );
    }
  });

  test.it("does not stamp implementing for an edit to the lane spec", () => {
    // Stage 5 appends the QC Plan to docs/superpowers/specs/lane-<slug>.md.
    // Without the exclusion that edit reads as code work, and because
    // `implementing` sits early in the pipeline the mis-read is only invisible
    // by luck (forward-only) — on a fix-loop re-entry it would be the live stage.
    const { reload } = require("../lib/pipelines");
    reload();
    const pipeline = getPipeline("ship-feature");
    for (const tool_name of ["Edit", "Write"]) {
      const r = detect(pipeline, {
        tool_name,
        tool_input: { file_path: "/lanes/lane1/docs/superpowers/specs/lane-foo.md" },
      });
      assert.notEqual(r && r.nodeId, "implementing", tool_name);
    }
  });

  test.it("never infers a terminal stage — merged and done are declaration-only", () => {
    // CLAUDE.md: an inferred node never renders `done`. Shipping a detect rule
    // for these nodes would be the one way to break that from the data side.
    const { reload } = require("../lib/pipelines");
    reload();
    const pipeline = getPipeline("ship-feature");
    for (const id of ["merged", "done", "e2e-feature-passed", "reported", "qc-plan"]) {
      const node = pipeline.nodes.find((n) => n.id === id);
      assert.deepEqual(node.detect, [], `${id} must carry no detect rule`);
    }
  });

  test.it("does not read the word push, or an unrelated ccam call, as a later stage", () => {
    const { reload } = require("../lib/pipelines");
    reload();
    const pipeline = getPipeline("ship-feature");
    for (const command of [
      'git commit -m "do not push this yet"',
      "ccam lanes logs 3 e2e",
      "ccam stage gates --status running",
      "gh pr diff 42",
    ]) {
      const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
      assert.equal(
        ["publishing", "pr-open", "watching-pr", "e2e-feature"].includes(r && r.nodeId),
        false,
        `${command} → ${r && r.nodeId}`
      );
    }
  });
});
