import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import test from 'node:test';

import { STAGES, stageByName } from './verify-release.mjs';

// SDLC-D-034 checkout invariant: publication in .woodpecker/publish.yml is
// bound to exact-commit terminal verification. This suite parses the real
// pipeline files and fails red when the gate is bypassed, weakened, or drifts
// out of sync with the canonical `pnpm verify:release` command. The negative
// controls for pipeline DAG/bypass shapes live in
// scripts/publish-gate-structure.test.mjs (RI-1-002); this file owns the
// canonical-command composition controls.

// Reuse the monorepo's existing YAML parser (@mosaicstack/mosaic's direct
// dependency) instead of adding a root dependency or vendoring a parser.
const mosaicRequire = createRequire(
  path.resolve(process.cwd(), 'packages', 'mosaic', 'package.json'),
);
const { parse: parseYaml } = mosaicRequire('yaml');

const publishYmlPath = path.join(process.cwd(), '.woodpecker', 'publish.yml');
const ciYmlPath = path.join(process.cwd(), '.woodpecker', 'ci.yml');

async function readPublishPipeline() {
  return parseYaml(await readFile(publishYmlPath, 'utf8'));
}

// A step has an external publication effect when its name starts with
// `publish` or when any command pushes an image to a registry.
function pushesImage(step) {
  return (step.commands ?? []).some((command) =>
    /(^|\s)(\/kaniko\/executor|docker push)\b|--destination/.test(command),
  );
}

function publishEffectSteps(pipeline) {
  return Object.entries(pipeline.steps ?? {})
    .filter(([name, step]) => name.startsWith('publish') || pushesImage(step))
    .map(([name]) => name);
}

// Transitive closure of a step's depends_on graph.
function dependencyClosure(pipeline, stepName, seen = new Set()) {
  const dependencies = pipeline.steps?.[stepName]?.depends_on ?? [];
  for (const dependency of dependencies) {
    if (seen.has(dependency)) continue;
    seen.add(dependency);
    dependencyClosure(pipeline, dependency, seen);
  }
  return seen;
}

function verifyCommands(pipeline) {
  const verify = pipeline.steps?.verify;
  assert.ok(verify, 'publish pipeline must define a `verify` step');
  assert.ok(Array.isArray(verify.commands), '`verify` step must have commands');
  return verify.commands;
}

function assertCommitIdentityAssertion(commands) {
  const text = commands.join('\n');
  assert.match(
    text,
    /CI_COMMIT_SHA/,
    '`verify` must compare the provider commit identity (CI_COMMIT_SHA)',
  );
  assert.match(text, /git rev-parse HEAD/, '`verify` must compare against git rev-parse HEAD');
  assert.match(
    text,
    /exit 1/,
    '`verify` must fail closed (exit 1) on identity mismatch or emptiness',
  );
}

function assertCanonicalCommand(commands) {
  assert.ok(
    commands.some((command) => /^pnpm verify:release\b/.test(command.trim())),
    '`verify` must run the canonical terminal verification command `pnpm verify:release`',
  );
}

function assertPublishGate(pipeline) {
  assert.ok(pipeline.steps, 'publish pipeline must define steps');

  const commands = verifyCommands(pipeline);
  assertCommitIdentityAssertion(commands);
  assertCanonicalCommand(commands);

  const effects = publishEffectSteps(pipeline);
  assert.ok(effects.length > 0, 'publish pipeline must contain publish effect steps to guard');

  for (const stepName of effects) {
    const step = pipeline.steps[stepName];
    assert.ok(
      Array.isArray(step.depends_on) && step.depends_on.includes('verify'),
      `publish effect '${stepName}' must depend DIRECTLY on the verify step (SDLC-D-034: transitively through build is not enough)`,
    );
    assert.ok(
      dependencyClosure(pipeline, stepName).has('verify'),
      `publish effect '${stepName}' must depend on a chain that includes verify`,
    );
  }
  return effects;
}

test('the publish pipeline gates every publish effect behind exact-commit verification', async () => {
  const pipeline = await readPublishPipeline();
  const effects = assertPublishGate(pipeline);
  assert.deepEqual(effects.sort(), [
    'build-appservice',
    'build-gateway',
    'build-web',
    'publish-next-npm',
    'publish-npm',
  ]);
});

test('the verify step carries no path/event short-circuit of its own', async () => {
  const pipeline = await readPublishPipeline();
  // A `when` filter on `verify` would let a publish effect fire on an event
  // class that skipped verification — the gate must be unconditional.
  assert.equal(pipeline.steps.verify.when, undefined);
});

test('a publish step that bypasses verify fails the gate checker', () => {
  // Negative fixture: a plausible publish pipeline where `publish-npm` hangs
  // off `build` only and `build` never chains to `verify` — the exact bypass
  // class SDLC-D-034 closes. The checker must go red on it.
  const bypassingPipeline = `
steps:
  install:
    image: node:24-alpine
    commands:
      - pnpm install --frozen-lockfile
  verify:
    image: node:24-alpine
    commands:
      - |
        if [ -z "$CI_COMMIT_SHA" ] || [ "$CI_COMMIT_SHA" != "$(git rev-parse HEAD)" ]; then
          echo "identity mismatch" >&2
          exit 1
        fi
      - pnpm verify:release
    depends_on:
      - install
  build:
    image: node:24-alpine
    commands:
      - pnpm build
    depends_on:
      - install
  publish-npm:
    image: node:24-alpine
    commands:
      - pnpm publish
    depends_on:
      - build
`;
  assert.throws(
    () => assertPublishGate(parseYaml(bypassingPipeline)),
    /publish-npm.*DIRECTLY.*verify/s,
  );
});

test('a publish step chained to verify only transitively fails the gate checker', () => {
  // Negative fixture: `build` depends on verify but `publish-npm` does not
  // carry the direct edge — weaker than SDLC-D-034 requires of the real DAG.
  const transitiveOnlyPipeline = `
steps:
  install:
    image: node:24-alpine
    commands:
      - pnpm install --frozen-lockfile
  verify:
    image: node:24-alpine
    commands:
      - |
        if [ -z "$CI_COMMIT_SHA" ] || [ "$CI_COMMIT_SHA" != "$(git rev-parse HEAD)" ]; then
          echo "identity mismatch" >&2
          exit 1
        fi
      - pnpm verify:release
    depends_on:
      - install
  build:
    image: node:24-alpine
    commands:
      - pnpm build
    depends_on:
      - install
      - verify
  publish-npm:
    image: node:24-alpine
    commands:
      - pnpm publish
    depends_on:
      - build
`;
  assert.throws(
    () => assertPublishGate(parseYaml(transitiveOnlyPipeline)),
    /publish-npm.*DIRECTLY.*verify/s,
  );
});

test('a verify step without the commit-identity assertion fails the gate checker', () => {
  const noIdentityPipeline = `
steps:
  verify:
    image: node:24-alpine
    commands:
      - pnpm verify:release
  publish-npm:
    image: node:24-alpine
    commands:
      - pnpm publish
    depends_on:
      - verify
`;
  assert.throws(() => assertPublishGate(parseYaml(noIdentityPipeline)), /CI_COMMIT_SHA/);
});

// The composition check: the canonical stage table must mirror the PR CI
// pipeline's complete mandatory set. Parameterized by the stage list so the
// subset negative control below can prove a dropped stage goes red (RI-1-002:
// the canonical command cannot silently lose a check).
function assertStagesMirrorCi(stages, ci) {
  const canonical = Object.fromEntries(stages.map((stage) => [stage.name, stage.commands]));

  // The complete mandatory set, in gate order. `quality-rails` is a
  // canonical-only stage (RI-N4, QC-19): like `build`, it has no ci.yml
  // mirror to match — its contract is asserted separately below.
  assert.deepEqual(
    stages.map((stage) => stage.name),
    [
      'sanitization',
      'upgrade-guard',
      'typecheck',
      'lint',
      'format',
      'test',
      'build',
      'quality-rails',
    ],
  );

  // Guard stages: ci.yml commands minus its `apk add` environment prep must be
  // exactly the canonical stage commands (order included).
  for (const stageName of ['sanitization', 'upgrade-guard']) {
    assert.deepEqual(
      ci.steps[stageName].commands.filter((command) => !command.startsWith('apk add')),
      canonical[stageName],
      `canonical '${stageName}' stage must match the ci.yml step`,
    );
  }

  // pnpm stages: ci.yml commands minus `corepack enable` must be exactly the
  // canonical stage commands.
  for (const stepName of ['typecheck', 'lint', 'format']) {
    assert.deepEqual(
      ci.steps[stepName].commands.filter((command) => command !== 'corepack enable'),
      canonical[stepName],
      `canonical '${stepName}' stage must match the ci.yml step`,
    );
  }

  // The test stage is shared, but ci.yml wraps it in pipeline-level
  // prerequisites the canonical command expects its caller to provide
  // (SDLC-D-034): the postgres service + readiness wait + db:migrate, openssl,
  // and the pinned pi runtime. None of those may be dropped silently.
  for (const command of canonical.test) {
    assert.ok(
      ci.steps.test.commands.includes(command),
      `ci.yml test step must run the canonical test stage command '${command}'`,
    );
  }
  for (const fragment of [
    'pg_isready -h ci-postgres',
    'pnpm --filter @mosaicstack/db run db:migrate',
    'npm install -g @earendil-works/pi-coding-agent@0.84.1',
  ]) {
    assert.ok(
      ci.steps.test.commands.some((command) => command.includes(fragment)),
      `ci.yml test step must keep its pipeline-level prerequisite '${fragment}'`,
    );
  }
}

test('the canonical verify:release stages mirror the PR CI pipeline one-for-one', async () => {
  const ci = parseYaml(await readFile(ciYmlPath, 'utf8'));
  assertStagesMirrorCi(STAGES, ci);
});

test('a subset stage list fails the composition check — a dropped stage cannot pass silently', async () => {
  const ci = parseYaml(await readFile(ciYmlPath, 'utf8'));
  // Drop each stage one at a time: every stage is load-bearing, so every drop
  // must go red. If any drop went green, a refactor could silently delete a
  // mandatory check from the canonical command.
  for (const stage of STAGES) {
    const subset = STAGES.filter((entry) => entry.name !== stage.name);
    assert.throws(
      () => assertStagesMirrorCi(subset, ci),
      Error,
      `composition check must fail when the '${stage.name}' stage is dropped from the table`,
    );
  }
});

test('the root package.json exposes verify:release as the canonical command', async () => {
  const packageJson = JSON.parse(await readFile(path.join(process.cwd(), 'package.json'), 'utf8'));
  assert.match(packageJson.scripts['verify:release'], /scripts\/verify-release\.mjs/);
});

// RI-N4 (card RI-3-002): the `quality-rails` stage must route through the TS
// evaluator instead of duplicating its presence logic inline. The evaluator
// owns QC-19; this file keeps that delegation honest.
function assertEvaluatorStage(stage) {
  assert.ok(stage, 'canonical stages must include a quality-rails stage');
  assert.ok(Array.isArray(stage.commands) && stage.commands.length > 0);
  for (const command of stage.commands) {
    assert.match(
      command,
      /packages\/quality-rails\/dist\/cli\.js.*quality-rails evaluate/,
      `quality-rails stage command must invoke the evaluator CLI, got: '${command}'`,
    );
  }
}

test('the quality-rails stage invokes the evaluator rather than duplicating its logic', () => {
  assertEvaluatorStage(stageByName('quality-rails'));
});

test('a quality-rails stage that re-implements presence logic inline fails the checker', () => {
  // Negative control: replacing the evaluator invocation with an inline
  // `test -f` presence loop is exactly the duplication RI-N4 forbids — the
  // checker must go red on it.
  const duplicated = {
    name: 'quality-rails',
    commands: ['test -f .husky/pre-commit && test -f .husky/pre-push'],
  };
  assert.throws(() => assertEvaluatorStage(duplicated), /must invoke the evaluator CLI/);
});

test('a quality-rails stage that silently drops the evaluator command fails the checker', () => {
  const empty = { name: 'quality-rails', commands: [] };
  assert.throws(() => assertEvaluatorStage(empty), /commands/);
});
