import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { DatabaseService } from '../services/DatabaseService';
import { encodeArtifactEvidenceJson } from '../services/gitops/json';
import { GitOpsStore, emptyTargetRow } from '../services/gitops/store';
import { GitOpsTransitions, type EventEnvelope } from '../services/gitops/transitions';
import { projectApplication } from '../services/gitops/derive';
import type { GitOpsApplicationRow, GitOpsGenerationRow } from '../services/gitops/types';

describe('gitops transitions', () => {
  let tmpDir: string;

  beforeAll(async () => {
    tmpDir = await setupTestDb();
    GitOpsStore.resetForTests();
    GitOpsTransitions.resetForTests();
  });

  afterAll(() => {
    cleanupTestDb(tmpDir);
  });

  it('binds desired+applied and source acceptance on Direct apply', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    const env = envelope('op-apply');
    tx.activateDirect({ application: app('app-apply', 'apply-web'), nodeId: 1, envelope: env });
    store.insertGeneration(gen('gen-apply', 'app-apply'));
    tx.fetchStarted('app-apply', envelope('op-fetch'));
    tx.fetched('app-apply', 'deadbeef', envelope('op-fetch'));
    tx.candidateReady('app-apply', 'gen-apply', false, envelope('op-cand'));
    tx.applyStarted('app-apply', 'gen-apply', envelope('op-apply'));
    tx.applied({
      applicationId: 'app-apply',
      generationId: 'gen-apply',
      artifactSetId: 'art-apply',
      sourceAcceptanceId: 'acc-apply',
      authority: 'operator',
      envelope: env,
    });
    const application = store.getApplication('app-apply')!;
    const target = store.getTarget('app-apply', 1)!;
    expect(application.accepted_generation_id).toBe('gen-apply');
    expect(application.source_acceptance_ref).toBe('acc-apply');
    expect(target.desired_generation_id).toBe('gen-apply');
    expect(target.applied_generation_id).toBe('gen-apply');
    expect(target.candidate_generation_id).toBeNull();
    expect(target.source_acceptance_ref).toBe('acc-apply');
    expect(target.expected_artifact_set_id).toBe('art-apply');
    expect(store.resolveApprovalRef('acc-apply', {
      kind: 'source_acceptance',
      applicationId: 'app-apply',
      generationId: 'gen-apply',
    })?.authoritative).toBe(1);
    expect(() => tx.applied({
      applicationId: 'app-apply',
      generationId: 'gen-other',
      artifactSetId: 'art-x',
      sourceAcceptanceId: 'acc-x',
      authority: 'operator',
      envelope: envelope('op-apply-2'),
    })).toThrow(/not the current candidate/);
  });

  it('advances expected only on first exact after unaccepted rows', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    seedApplied('app-art', 'art-web', 'gen-art', 'art-v1', 'acc-art');
    tx.recordArtifactEvidence({
      applicationId: 'app-art',
      generationId: 'gen-art',
      artifactSetId: 'art-v2',
      evidenceVersion: 2,
      qualification: 'unavailable',
      evidenceJson: encodeArtifactEvidenceJson({ kind: 'unavailable' }),
      authoritative: 0,
      envelope: envelope('op-art-2'),
    });
    tx.recordArtifactEvidence({
      applicationId: 'app-art',
      generationId: 'gen-art',
      artifactSetId: 'art-v3',
      evidenceVersion: 3,
      qualification: 'exact',
      evidenceJson: encodeArtifactEvidenceJson({ kind: 'exact', identity: 'sha256:aaa' }),
      authoritative: 0,
      envelope: envelope('op-art-3'),
    });
    const application = store.getApplication('app-art')!;
    expect(application.artifact_set_id).toBe('art-v3');
    expect(application.latest_artifact_set_id).toBe('art-v3');
    tx.recordArtifactEvidence({
      applicationId: 'app-art',
      generationId: 'gen-art',
      artifactSetId: 'art-v4',
      evidenceVersion: 4,
      qualification: 'stale',
      evidenceJson: encodeArtifactEvidenceJson({ kind: 'stale', identity: 'sha256:aaa' }),
      authoritative: 0,
      envelope: envelope('op-art-4'),
    });
    expect(store.getApplication('app-art')?.artifact_set_id).toBe('art-v3');
    expect(store.getApplication('app-art')?.latest_artifact_set_id).toBe('art-v4');
    tx.recordArtifactEvidence({
      applicationId: 'app-art',
      generationId: 'gen-art',
      artifactSetId: 'art-v5',
      evidenceVersion: 5,
      qualification: 'exact',
      evidenceJson: encodeArtifactEvidenceJson({ kind: 'exact', identity: 'sha256:bbb' }),
      authoritative: 0,
      envelope: envelope('op-art-5'),
    });
    expect(store.getApplication('app-art')?.artifact_set_id).toBe('art-v3');
    expect(store.getApplication('app-art')?.latest_artifact_set_id).toBe('art-v5');
    expect(store.getTarget('app-art', 1)?.expected_artifact_set_id).toBe('art-v3');
    expect(store.getTarget('app-art', 1)?.latest_artifact_set_id).toBe('art-v5');
  });

  it('clears fetch failure on successful fetch and keeps accepted pointers', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    seedApplied('app-fail', 'fail-web', 'gen-fail', 'art-fail', 'acc-fail');
    tx.fetchStarted('app-fail', envelope('op-fail'));
    tx.fetchFailed('app-fail', envelope('op-fail'));
    expect(store.getApplication('app-fail')?.failure_stage).toBe('fetch');
    expect(store.getApplication('app-fail')?.accepted_generation_id).toBe('gen-fail');
    tx.fetchStarted('app-fail', envelope('op-fail-2'));
    tx.fetched('app-fail', 'cafebabe', envelope('op-fail-2'));
    const application = store.getApplication('app-fail')!;
    expect(application.failure_stage).toBeNull();
    expect(application.desired_commit_sha).toBe('cafebabe');
    expect(application.accepted_generation_id).toBe('gen-fail');
    expect(store.getTarget('app-fail', 1)?.applied_generation_id).toBe('gen-fail');
  });

  it('rejects a candidate whose fingerprint no longer matches configuration', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    tx.activateDirect({ application: app('app-fp', 'fp-web'), nodeId: 1, envelope: envelope('op-act-fp') });
    store.insertGeneration(gen('gen-fp', 'app-fp'));
    DatabaseService.getInstance().getDb().prepare(
      "UPDATE gitops_applications SET materialization_fingerprint = ? WHERE id = 'app-fp'",
    ).run('b'.repeat(64));
    expect(() => tx.candidateReady('app-fp', 'gen-fp', false, envelope('op-c-fp'))).toThrow(/fingerprint/);
    DatabaseService.getInstance().getDb().prepare(
      "UPDATE gitops_applications SET materialization_fingerprint = ? WHERE id = 'app-fp'",
    ).run('a'.repeat(64));
    tx.fetchStarted('app-fp', envelope('op-f-fp'));
    tx.fetched('app-fp', 'abc123', envelope('op-f-fp'));
    tx.candidateReady('app-fp', 'gen-fp', false, envelope('op-c-fp2'));
    DatabaseService.getInstance().getDb().prepare(
      "UPDATE gitops_applications SET materialization_fingerprint = ? WHERE id = 'app-fp'",
    ).run('c'.repeat(64));
    expect(() => tx.applyStarted('app-fp', 'gen-fp', envelope('op-a-fp'))).toThrow(/fingerprint/);
  });

  it('refuses to replace the candidate while an apply is in flight', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    tx.activateDirect({ application: app('app-race', 'race-web'), nodeId: 1, envelope: envelope('op-act-race') });
    store.insertGeneration(gen('gen-race-a', 'app-race'));
    store.insertGeneration(gen('gen-race-b', 'app-race'));
    tx.fetchStarted('app-race', envelope('op-f-race'));
    tx.fetched('app-race', 'abc123', envelope('op-f-race'));
    tx.candidateReady('app-race', 'gen-race-a', false, envelope('op-c-race-a'));
    tx.applyStarted('app-race', 'gen-race-a', envelope('op-a-race'));
    expect(() => tx.candidateReady('app-race', 'gen-race-b', false, envelope('op-c-race-b')))
      .toThrow(/apply is in flight/);
    expect(store.getApplication('app-race')?.candidate_generation_id).toBe('gen-race-a');
    tx.applied({
      applicationId: 'app-race',
      generationId: 'gen-race-a',
      artifactSetId: 'art-race',
      sourceAcceptanceId: 'acc-race',
      authority: 'operator',
      envelope: envelope('op-a-race'),
    });
    expect(store.getApplication('app-race')?.accepted_generation_id).toBe('gen-race-a');
  });

  it('rejects re-accepting a generation that is already accepted', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    seedApplied('app-reapply', 'reapply-web', 'gen-reapply', 'art-reapply', 'acc-reapply');
    tx.candidateReady('app-reapply', 'gen-reapply', false, envelope('op-c-reapply-2'));
    expect(() => tx.applied({
      applicationId: 'app-reapply',
      generationId: 'gen-reapply',
      artifactSetId: 'art-reapply-2',
      sourceAcceptanceId: 'acc-reapply-2',
      authority: 'operator',
      envelope: envelope('op-a-reapply-2'),
    })).toThrow(/already accepted/);
    expect(store.getArtifactSet('art-reapply-2')).toBeUndefined();
  });

  it('surfaces an invalid stored observation as a limitation instead of a clean unknown', () => {
    const store = GitOpsStore.getInstance();
    seedApplied('app-obs', 'obs-web', 'gen-obs', 'art-obs', 'acc-obs');
    DatabaseService.getInstance().getDb().prepare(
      "UPDATE gitops_target_current SET observed_artifact_identity_json = ? WHERE application_id = 'app-obs'",
    ).run('{"kind":"nonsense"}');
    const projection = mustProject('app-obs');
    expect(projection.limitations.map((l) => l.code)).toContain('artifact_observation_invalid');
    expect(store.getTarget('app-obs', 1)?.observed_artifact_identity_json).toBe('{"kind":"nonsense"}');
  });

  it('advances the fetched SHA on an invalid commit without minting a candidate', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    tx.activateDirect({ application: app('app-inv', 'inv-web'), nodeId: 1, envelope: envelope('op-act-inv') });
    tx.fetchStarted('app-inv', envelope('op-f-inv'));
    tx.fetchedInvalid('app-inv', 'bad1234', envelope('op-f-inv'));
    const application = store.getApplication('app-inv')!;
    expect(application.desired_commit_sha).toBe('bad1234');
    expect(application.fetched_commit_sha).toBe('bad1234');
    expect(application.candidate_generation_id).toBeNull();
    expect(application.failure_stage).toBe('validation');
    expect(mustProject('app-inv').facets.source.status).toBe('source_failed');
  });

  it('exposes a blocked candidate without allowing it to apply', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    tx.activateDirect({ application: app('app-blk', 'blk-web'), nodeId: 1, envelope: envelope('op-act-blk') });
    store.insertGeneration({ ...gen('gen-blk', 'app-blk'), plan_blocked: 1 });
    tx.fetchStarted('app-blk', envelope('op-f-blk'));
    tx.fetched('app-blk', 'abc123', envelope('op-f-blk'));
    tx.sourceConflictBlocker('app-blk', 'gen-blk', envelope('op-b-blk'));
    expect(store.getApplication('app-blk')?.candidate_plan_blocked).toBe(1);
    expect(store.getTarget('app-blk', 1)?.candidate_generation_id).toBe('gen-blk');
    const projection = mustProject('app-blk');
    expect(projection.facets.source.status).toBe('source_conflict_blocker');
    expect(projection.availableActions).not.toContain('apply');
    expect(() => tx.applyStarted('app-blk', 'gen-blk', envelope('op-a-blk'))).toThrow(/blocked/);
  });

  it('dismisses a candidate without touching what is already applied', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    seedApplied('app-dis', 'dis-web', 'gen-dis', 'art-dis', 'acc-dis');
    store.insertGeneration(gen('gen-dis-2', 'app-dis'));
    tx.candidateReady('app-dis', 'gen-dis-2', false, envelope('op-c-dis'));
    tx.dismissed('app-dis', envelope('op-d-dis'));
    const application = store.getApplication('app-dis')!;
    expect(application.candidate_generation_id).toBeNull();
    expect(application.accepted_generation_id).toBe('gen-dis');
    expect(store.getTarget('app-dis', 1)?.applied_generation_id).toBe('gen-dis');
    expect(store.getTarget('app-dis', 1)?.candidate_generation_id).toBeNull();
  });

  it('refuses to dismiss while an operation is in flight', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    tx.activateDirect({ application: app('app-dis2', 'dis2-web'), nodeId: 1, envelope: envelope('op-act-dis2') });
    store.insertGeneration(gen('gen-dis2', 'app-dis2'));
    tx.fetchStarted('app-dis2', envelope('op-f-dis2'));
    tx.fetched('app-dis2', 'abc123', envelope('op-f-dis2'));
    tx.candidateReady('app-dis2', 'gen-dis2', false, envelope('op-c-dis2'));
    tx.applyStarted('app-dis2', 'gen-dis2', envelope('op-a-dis2'));
    expect(() => tx.dismissed('app-dis2', envelope('op-d-dis2'))).toThrow(/in flight/);
    expect(store.getApplication('app-dis2')?.candidate_generation_id).toBe('gen-dis2');
  });

  it('invalidates a staged candidate when the material configuration changes', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    seedApplied('app-cfg', 'cfg-web', 'gen-cfg', 'art-cfg', 'acc-cfg');
    store.insertGeneration(gen('gen-cfg-2', 'app-cfg'));
    tx.candidateReady('app-cfg', 'gen-cfg-2', false, envelope('op-c-cfg'));
    tx.configChangedPendingCleared({
      applicationId: 'app-cfg',
      identity: {
        repoUrl: 'https://github.com/org/other.git',
        repoIdentityJson: '{"host":"github.com","pathname":"/org/other.git"}',
        configuredRef: 'release',
      },
      material: {
        composePathsJson: '["compose.yml","compose.prod.yml"]',
        contextDir: null,
        syncEnv: 0,
        envPath: null,
        fingerprint: 'd'.repeat(64),
      },
      envelope: envelope('op-cfg'),
    });
    const application = store.getApplication('app-cfg')!;
    expect(application.configured_ref).toBe('release');
    expect(application.materialization_fingerprint).toBe('d'.repeat(64));
    expect(application.desired_commit_sha).toBeNull();
    expect(application.candidate_generation_id).toBeNull();
    // The workload that is running did not change because the config did.
    expect(application.accepted_generation_id).toBe('gen-cfg');
    expect(store.getTarget('app-cfg', 1)?.applied_generation_id).toBe('gen-cfg');
    const projection = mustProject('app-cfg');
    expect(projection.facets.source.status).toBe('source_reconcile_required');
    expect(projection.availableActions).toContain('fetch');
  });

  it('records deploy failures without moving the deployed generation', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    seedApplied('app-dep', 'dep-web', 'gen-dep', 'art-dep', 'acc-dep');
    tx.deployStarted('app-dep', 1, 'gen-dep', envelope('op-dep-1'));
    tx.deployUnbound('app-dep', 1, 'gen-dep', envelope('op-dep-1'));
    let target = store.getTarget('app-dep', 1)!;
    expect(target.deployed_generation_id).toBeNull();
    expect(target.failure_class).toBe('unbound');
    expect(mustProject('app-dep').targets[0]?.runtime.status).toBe('failed_previous_workload_intact');

    tx.deployStarted('app-dep', 1, 'gen-dep', envelope('op-dep-2'));
    tx.deployFailed('app-dep', 1, 'post_mutation', envelope('op-dep-2'));
    target = store.getTarget('app-dep', 1)!;
    expect(target.deployed_generation_id).toBeNull();
    expect(target.failure_class).toBe('post_mutation');
    expect(mustProject('app-dep').targets[0]?.runtime.status).toBe('failed_after_mutation');

    // A later success clears the failure in the same move as the pointer.
    tx.deployStarted('app-dep', 1, 'gen-dep', envelope('op-dep-3'));
    tx.deployBound('app-dep', 1, 'gen-dep', envelope('op-dep-3'));
    target = store.getTarget('app-dep', 1)!;
    expect(target.deployed_generation_id).toBe('gen-dep');
    expect(target.failure_stage).toBeNull();
  });

  it('promotes healthy and last-known-good only for the generation the run watched', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    seedApplied('app-hl', 'hl-web', 'gen-hl', 'art-hl', 'acc-hl');
    tx.deployStarted('app-hl', 1, 'gen-hl', envelope('op-hl-dep'));
    tx.deployBound('app-hl', 1, 'gen-hl', envelope('op-hl-dep'));

    // A verdict for a generation that is not the deployed one proves nothing.
    tx.healthFinalized({
      applicationId: 'app-hl',
      nodeId: 1,
      healthRunId: 'run-stale',
      healthStatus: 'passed',
      deployedGenerationId: 'gen-other',
      targetScope: 'stack',
      envelope: envelope('op-hl-stale'),
    });
    expect(store.getTarget('app-hl', 1)?.healthy_generation_id).toBeNull();

    // Nor does a service-scoped run, which never observed the whole stack.
    tx.healthFinalized({
      applicationId: 'app-hl',
      nodeId: 1,
      healthRunId: 'run-service',
      healthStatus: 'passed',
      deployedGenerationId: 'gen-hl',
      targetScope: 'service',
      envelope: envelope('op-hl-service'),
    });
    expect(store.getTarget('app-hl', 1)?.healthy_generation_id).toBeNull();

    // Nor does a failure.
    tx.healthFinalized({
      applicationId: 'app-hl',
      nodeId: 1,
      healthRunId: 'run-failed',
      healthStatus: 'failed',
      deployedGenerationId: 'gen-hl',
      targetScope: 'stack',
      envelope: envelope('op-hl-failed'),
    });
    expect(store.getTarget('app-hl', 1)?.healthy_generation_id).toBeNull();

    tx.healthFinalized({
      applicationId: 'app-hl',
      nodeId: 1,
      healthRunId: 'run-pass',
      healthStatus: 'passed',
      deployedGenerationId: 'gen-hl',
      targetScope: 'stack',
      envelope: envelope('op-hl-pass'),
    });
    const target = store.getTarget('app-hl', 1)!;
    expect(target.healthy_generation_id).toBe('gen-hl');
    expect(target.lkg_generation_id).toBe('gen-hl');
    // The expected artifact belongs to this generation, so it is kept as the
    // qualification evidence for the last-known-good.
    expect(target.lkg_artifact_set_id).toBe('art-hl');
    expect(target.lkg_unavailable_at).toBeNull();
    const projection = mustProject('app-hl');
    expect(projection.targets[0]?.runtime.status).toBe('synced_and_healthy');
    expect(projection.targets[0]?.lkg.status).not.toBe('none');
  });

  it('keeps the last-known-good generation when its artifact belongs elsewhere', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    seedApplied('app-lkg', 'lkg-web', 'gen-lkg', 'art-lkg', 'acc-lkg');
    tx.deployStarted('app-lkg', 1, 'gen-lkg', envelope('op-lkg-dep'));
    tx.deployBound('app-lkg', 1, 'gen-lkg', envelope('op-lkg-dep'));
    // Clear the expectation so the promotion has no artifact to qualify with.
    DatabaseService.getInstance().getDb().prepare(
      "UPDATE gitops_target_current SET expected_artifact_set_id = NULL WHERE application_id = 'app-lkg'",
    ).run();

    tx.healthFinalized({
      applicationId: 'app-lkg',
      nodeId: 1,
      healthRunId: 'run-lkg',
      healthStatus: 'passed',
      deployedGenerationId: 'gen-lkg',
      targetScope: 'stack',
      envelope: envelope('op-lkg-pass'),
    });

    const target = store.getTarget('app-lkg', 1)!;
    // The generation is still good; only its executable identity is unproven.
    expect(target.lkg_generation_id).toBe('gen-lkg');
    expect(target.lkg_artifact_set_id).toBeNull();
    expect(mustProject('app-lkg').targets[0]?.lkg.status).toBe('available');
  });

  it('tombstones an application and its target, and never reactivates it', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    seedApplied('app-tomb', 'tomb-web', 'gen-tomb', 'art-tomb', 'acc-tomb');
    tx.targetTombstoned('app-tomb', 1, envelope('op-tomb'));
    tx.applicationTombstoned('app-tomb', 'detached', envelope('op-tomb'));
    const application = store.getApplication('app-tomb')!;
    expect(application.lifecycle_status).toBe('detached');
    // Configured identity survives as a frozen fact.
    expect(application.configured_repo_url).toBe('https://github.com/org/repo.git');
    expect(application.desired_commit_sha).toBe('abc123');
    expect(store.getTarget('app-tomb', 1)?.target_status).toBe('tombstoned');
    expect(store.getLiveDirectApplication('tomb-web')).toBeUndefined();
    expect(() => tx.applicationTombstoned('app-tomb', 'deleted', envelope('op-tomb-2')))
      .toThrow(/already tombstoned/);
    expect(mustProject('app-tomb').facets.source.status).toBe('not_live');
  });

  it('retires every live target on a node without touching its applications', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    seedApplied('app-node-a', 'node-a-web', 'gen-node-a', 'art-node-a', 'acc-node-a');
    seedApplied('app-node-b', 'node-b-web', 'gen-node-b', 'art-node-b', 'acc-node-b');

    tx.tombstoneNodeTargets(1, envelope('op-node-del'));

    expect(store.getTarget('app-node-a', 1)?.target_status).toBe('tombstoned');
    expect(store.getTarget('app-node-b', 1)?.target_status).toBe('tombstoned');
    // The applications still describe real stacks, so they stay live.
    expect(store.getApplication('app-node-a')?.lifecycle_status).toBe('active');
    expect(store.getApplication('app-node-b')?.lifecycle_status).toBe('active');
    // Replaying finds nothing left to retire.
    expect(tx.tombstoneNodeTargets(1, envelope('op-node-del-2')).historyIds).toHaveLength(0);
  });

  it('rejects terminal events with no matching operation', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    tx.activateDirect({ application: app('app-guard', 'guard-web'), nodeId: 1, envelope: envelope('op-act-guard') });
    store.insertGeneration(gen('gen-guard', 'app-guard'));
    expect(() => tx.applyFailed('app-guard', 'apply', envelope('op-g1')))
      .toThrow(/no matching apply operation/);
    expect(() => tx.deployStarted('app-guard', 1, 'gen-guard', envelope('op-g2')))
      .toThrow(/not applied/);
    expect(() => tx.deployBound('app-guard', 1, 'gen-guard', envelope('op-g3')))
      .toThrow(/no matching deploy operation/);
    expect(store.getTarget('app-guard', 1)?.deployed_generation_id).toBeNull();
  });

  it('writes one history row per transition with the bound identity', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    tx.activateDirect({ application: app('app-hist', 'hist-web'), nodeId: 1, envelope: envelope('op-act-hist') });
    store.insertGeneration(gen('gen-hist', 'app-hist'));
    tx.fetchStarted('app-hist', envelope('op-f-hist'));
    tx.fetched('app-hist', 'abc123', envelope('op-f-hist'));
    tx.candidateReady('app-hist', 'gen-hist', false, envelope('op-c-hist'));
    const applied = tx.applied({
      applicationId: 'app-hist',
      generationId: 'gen-hist',
      artifactSetId: 'art-hist',
      sourceAcceptanceId: 'acc-hist',
      authority: 'operator',
      envelope: envelope('op-a-hist'),
    });
    expect(applied.replayed).toBe(false);
    expect(applied.historyIds).toHaveLength(1);
    const row = DatabaseService.getInstance().getDb().prepare(
      'SELECT stage, outcome, dedupe_target, generation_id, artifact_set_id, source_acceptance_ref, node_id FROM gitops_history WHERE id = ?',
    ).get(applied.historyIds[0]) as Record<string, unknown>;
    expect(row.stage).toBe('applied');
    expect(row.outcome).toBe('committed');
    expect(row.dedupe_target).toBe('app');
    expect(row.generation_id).toBe('gen-hist');
    expect(row.artifact_set_id).toBe('art-hist');
    expect(row.source_acceptance_ref).toBe('acc-hist');
    expect(row.node_id).toBeNull();
  });

  it('interrupts live apply and binds deploy after applied', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    tx.activateDirect({ application: app('app-int', 'int-web'), nodeId: 1, envelope: envelope('op-act-int') });
    store.insertGeneration(gen('gen-int', 'app-int'));
    tx.fetchStarted('app-int', envelope('op-f-int'));
    tx.fetched('app-int', 'abc123', envelope('op-f-int'));
    tx.candidateReady('app-int', 'gen-int', false, envelope('op-c-int'));
    tx.applyStarted('app-int', 'gen-int', envelope('op-apply-live'));
    expect(mustProject('app-int').facets.source.status).toBe('applying');
    tx.interruptActiveOperations('app-int', envelope('op-boot'));
    expect(mustProject('app-int').facets.source.status).toBe('source_unknown');
    tx.applied({
      applicationId: 'app-int',
      generationId: 'gen-int',
      artifactSetId: 'art-int',
      sourceAcceptanceId: 'acc-int',
      authority: 'operator',
      envelope: envelope('op-a-int'),
    });
    tx.deployStarted('app-int', 1, 'gen-int', envelope('op-dep'));
    tx.deployBound('app-int', 1, 'gen-int', envelope('op-dep'));
    expect(store.getTarget('app-int', 1)?.deployed_generation_id).toBe('gen-int');
    expect(store.getTarget('app-int', 1)?.failure_stage).toBeNull();
  });

  it('sourceAccepted accepts the generation without touching any target', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    const env = envelope('op-sa');
    tx.activateDirect({ application: app('app-sa', 'sa-web'), nodeId: 1, envelope: env });
    store.insertGeneration(gen('gen-sa', 'app-sa'));
    tx.fetchStarted('app-sa', envelope('op-f-sa'));
    tx.fetched('app-sa', 'deadbeef', envelope('op-f-sa'));
    tx.candidateReady('app-sa', 'gen-sa', false, envelope('op-c-sa'));
    tx.applyStarted('app-sa', 'gen-sa', envelope('op-sa'));

    const result = tx.sourceAccepted({
      applicationId: 'app-sa',
      generationId: 'gen-sa',
      artifactSetId: 'art-sa',
      sourceAcceptanceId: 'acc-sa',
      authority: 'operator',
      envelope: env,
    });

    expect(result.replayed).toBe(false);
    const application = store.getApplication('app-sa')!;
    expect(application.accepted_generation_id).toBe('gen-sa');
    expect(application.source_acceptance_ref).toBe('acc-sa');
    expect(application.candidate_generation_id).toBeNull();
    // sourceAccepted is mode-neutral: it must not bind the Direct target.
    const target = store.getTarget('app-sa', 1)!;
    expect(target.applied_generation_id).toBeNull();
    expect(target.desired_generation_id).toBeNull();
    expect(target.candidate_generation_id).toBe('gen-sa');
  });

  it('sourceAccepted refuses to accept a candidate while the source is suspended', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    const env = envelope('op-sas');
    tx.activateDirect({ application: app('app-sas', 'sas-web'), nodeId: 1, envelope: env });
    store.insertGeneration(gen('gen-sas', 'app-sas'));
    tx.fetchStarted('app-sas', envelope('op-f-sas'));
    tx.fetched('app-sas', 'deadbeef', envelope('op-f-sas'));
    tx.candidateReady('app-sas', 'gen-sas', false, envelope('op-c-sas'));
    tx.applyStarted('app-sas', 'gen-sas', envelope('op-sas'));
    tx.sourceSuspended('app-sas', 'operator paused sync', envelope('op-susp-sas'));

    expect(() => tx.sourceAccepted({
      applicationId: 'app-sas',
      generationId: 'gen-sas',
      artifactSetId: 'art-sas',
      sourceAcceptanceId: 'acc-sas',
      authority: 'operator',
      envelope: envelope('op-sas-2'),
    })).toThrow(/suspended/);

    const application = store.getApplication('app-sas')!;
    expect(application.accepted_generation_id).toBeNull();
    expect(application.candidate_generation_id).toBe('gen-sas');
    expect(application.suspended_at).not.toBeNull();
  });

  it('targetApplied binds a Direct target only after the generation is accepted', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    const env = envelope('op-ta');
    tx.activateDirect({ application: app('app-ta', 'ta-web'), nodeId: 1, envelope: env });
    store.insertGeneration(gen('gen-ta', 'app-ta'));
    tx.fetchStarted('app-ta', envelope('op-f-ta'));
    tx.fetched('app-ta', 'deadbeef', envelope('op-f-ta'));
    tx.candidateReady('app-ta', 'gen-ta', false, envelope('op-c-ta'));
    tx.applyStarted('app-ta', 'gen-ta', envelope('op-ta'));
    tx.sourceAccepted({
      applicationId: 'app-ta',
      generationId: 'gen-ta',
      artifactSetId: 'art-ta',
      sourceAcceptanceId: 'acc-ta',
      authority: 'operator',
      envelope: env,
    });

    const result = tx.targetApplied(1, {
      applicationId: 'app-ta',
      generationId: 'gen-ta',
      artifactSetId: 'art-ta',
      sourceAcceptanceId: 'acc-ta',
      authority: 'operator',
      envelope: env,
    });

    expect(result.replayed).toBe(false);
    const target = store.getTarget('app-ta', 1)!;
    expect(target.applied_generation_id).toBe('gen-ta');
    expect(target.desired_generation_id).toBe('gen-ta');
    expect(target.candidate_generation_id).toBeNull();
    expect(target.expected_artifact_set_id).toBe('art-ta');
    expect(target.source_acceptance_ref).toBe('acc-ta');
  });

  it('targetApplied refuses a delayed dispatch that would erase a newer candidate', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    const env = envelope('op-tan');
    tx.activateDirect({ application: app('app-tan', 'tan-web'), nodeId: 1, envelope: env });
    store.insertGeneration(gen('gen-tan-1', 'app-tan'));
    tx.fetchStarted('app-tan', envelope('op-f-tan-1'));
    tx.fetched('app-tan', 'deadbeef', envelope('op-f-tan-1'));
    tx.candidateReady('app-tan', 'gen-tan-1', false, envelope('op-c-tan-1'));
    tx.applyStarted('app-tan', 'gen-tan-1', envelope('op-tan'));
    tx.sourceAccepted({
      applicationId: 'app-tan',
      generationId: 'gen-tan-1',
      artifactSetId: 'art-tan-1',
      sourceAcceptanceId: 'acc-tan-1',
      authority: 'operator',
      envelope: env,
    });
    // A newer revision arrives and supersedes generation 1 as the target's
    // current candidate, before generation 1's dispatch ever binds a target.
    store.insertGeneration(gen('gen-tan-2', 'app-tan'));
    tx.fetchStarted('app-tan', envelope('op-f-tan-2'));
    tx.fetched('app-tan', 'cafed00d', envelope('op-f-tan-2'));
    tx.candidateReady('app-tan', 'gen-tan-2', false, envelope('op-c-tan-2'));

    // Generation 1's delayed dispatch must not silently erase generation 2's
    // candidate, even though generation 1 is (still) the accepted generation.
    expect(() => tx.targetApplied(1, {
      applicationId: 'app-tan',
      generationId: 'gen-tan-1',
      artifactSetId: 'art-tan-1',
      sourceAcceptanceId: 'acc-tan-1',
      authority: 'operator',
      envelope: envelope('op-ta-delayed'),
    })).toThrow(/candidate/);

    const target = store.getTarget('app-tan', 1)!;
    expect(target.candidate_generation_id).toBe('gen-tan-2');
    expect(target.applied_generation_id).toBeNull();
  });

  it('targetApplied refuses a source acceptance reference that does not match what was accepted', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    const env = envelope('op-tar2');
    tx.activateDirect({ application: app('app-tar2', 'tar2-web'), nodeId: 1, envelope: env });
    store.insertGeneration(gen('gen-tar2', 'app-tar2'));
    tx.fetchStarted('app-tar2', envelope('op-f-tar2'));
    tx.fetched('app-tar2', 'deadbeef', envelope('op-f-tar2'));
    tx.candidateReady('app-tar2', 'gen-tar2', false, envelope('op-c-tar2'));
    tx.applyStarted('app-tar2', 'gen-tar2', envelope('op-tar2'));
    tx.sourceAccepted({
      applicationId: 'app-tar2',
      generationId: 'gen-tar2',
      artifactSetId: 'art-tar2',
      sourceAcceptanceId: 'acc-tar2-real',
      authority: 'operator',
      envelope: env,
    });

    expect(() => tx.targetApplied(1, {
      applicationId: 'app-tar2',
      generationId: 'gen-tar2',
      artifactSetId: 'art-tar2',
      sourceAcceptanceId: 'acc-tar2-forged',
      authority: 'operator',
      envelope: envelope('op-ta-forged'),
    })).toThrow(/acceptance/);

    const target = store.getTarget('app-tar2', 1)!;
    expect(target.source_acceptance_ref).not.toBe('acc-tar2-forged');
  });

  it('targetApplied refuses to bind a target on a non-Direct application', async () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    const { blankInlineApplication } = await import('../services/gitops/blueprintProducers');
    const env = envelope('op-tam');

    // A genuine Blueprint application: no Git identity, so the "accepted
    // generation" and target row below are constructed directly rather than
    // through the Direct fetch/candidate/apply path, which this mode does
    // not have.
    tx.activateInlineBlueprint({ application: blankInlineApplication('app-tam', 900, env.at), envelope: env });
    store.insertGeneration(gen('gen-tam', 'app-tam'));
    DatabaseService.getInstance().getDb().prepare(
      "UPDATE gitops_applications SET accepted_generation_id = 'gen-tam' WHERE id = 'app-tam'",
    ).run();
    store.upsertTarget(emptyTargetRow('app-tam', 1, env.at));

    expect(() => tx.targetApplied(1, {
      applicationId: 'app-tam',
      generationId: 'gen-tam',
      artifactSetId: 'art-tam',
      sourceAcceptanceId: 'acc-tam',
      authority: 'operator',
      envelope: env,
    })).toThrow(/direct/i);
  });

  it('targetApplied refuses a generation the application has not accepted', () => {
    const store = GitOpsStore.getInstance();
    const tx = GitOpsTransitions.getInstance();
    const env = envelope('op-tar');
    tx.activateDirect({ application: app('app-tar', 'tar-web'), nodeId: 1, envelope: env });
    store.insertGeneration(gen('gen-tar', 'app-tar'));
    tx.fetchStarted('app-tar', envelope('op-f-tar'));
    tx.fetched('app-tar', 'deadbeef', envelope('op-f-tar'));
    tx.candidateReady('app-tar', 'gen-tar', false, envelope('op-c-tar'));

    expect(() => tx.targetApplied(1, {
      applicationId: 'app-tar',
      generationId: 'gen-tar',
      artifactSetId: 'art-tar',
      sourceAcceptanceId: 'acc-tar',
      authority: 'operator',
      envelope: env,
    })).toThrow(/not accepted/);
  });
});

function mustProject(applicationId: string) {
  const projection = projectApplication(applicationId, false);
  if (projection.targetMode === 'not_applicable') throw new Error('expected application');
  return projection;
}

function seedApplied(
  applicationId: string,
  stackName: string,
  generationId: string,
  artifactSetId: string,
  sourceAcceptanceId: string,
): void {
  const store = GitOpsStore.getInstance();
  const tx = GitOpsTransitions.getInstance();
  tx.activateDirect({ application: app(applicationId, stackName), nodeId: 1, envelope: envelope(`op-act-${applicationId}`) });
  store.insertGeneration(gen(generationId, applicationId));
  tx.fetchStarted(applicationId, envelope(`op-f-${applicationId}`));
  tx.fetched(applicationId, 'abc123', envelope(`op-f-${applicationId}`));
  tx.candidateReady(applicationId, generationId, false, envelope(`op-c-${applicationId}`));
  tx.applied({
    applicationId,
    generationId,
    artifactSetId,
    sourceAcceptanceId,
    authority: 'operator',
    envelope: envelope(`op-a-${applicationId}`),
  });
}

function envelope(operationId: string): EventEnvelope {
  return { operationId, actor: 'tester', trigger: 'manual', at: Date.now() };
}

function app(id: string, stackName: string): GitOpsApplicationRow {
  return {
    id,
    lifecycle_key: `direct:${stackName}`,
    lifecycle_status: 'active',
    target_mode: 'direct',
    stack_name: stackName,
    blueprint_id: null,
    configured_repo_url: 'https://github.com/org/repo.git',
    repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
    configured_ref: 'main',
    compose_paths_json: '["compose.yml"]',
    context_dir: null,
    sync_env: 0,
    env_path: null,
    materialization_fingerprint: 'a'.repeat(64),
    desired_commit_sha: null,
    fetched_commit_sha: null,
    fetched_resolved_ref_kind: null,
    candidate_generation_id: null,
    accepted_generation_id: null,
    candidate_plan_blocked: 0,
    review_required: 0,
    artifact_set_id: null,
    latest_artifact_set_id: null,
    intent_revision_id: null,
    rollout_candidate_id: null,
    rollout_generation_id: null,
    source_acceptance_ref: null,
    placement_approval_ref: null,
    rollout_authorization_ref: null,
    legacy_combined_approval_ref: null,
    preflight_fingerprint: null,
    latest_operation_id: null,
    active_operation_id: null,
    active_operation_stage: null,
    active_operation_at: null,
    active_generation_id: null,
    pause_at: null,
    pause_reason: null,
    source_suspended_reason: null,
    partial_json: null,
    failure_stage: null,
    failure_class: null,
    failure_at: null,
    retry_at: null,
    retry_count: 0,
    suspended_at: null,
    recovery_ref: null,
    recovery_phase: null,
    interruption_stage: null,
    interruption_at: null,
    interruption_operation_id: null,
    interruption_generation_id: null,
    evidence_fresh_at: null,
    evidence_limitations_json: null,
    created_at: 1,
    updated_at: 1,
  };
}

function gen(id: string, applicationId: string): GitOpsGenerationRow {
  return {
    id,
    application_id: applicationId,
    commit_sha: 'abc123',
    repo_url: 'https://github.com/org/repo.git',
    resolved_ref_kind: 'branch',
    configured_ref: 'main',
    repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
    manifest_version: 0,
    candidate_dir: `generations/candidate-${id}`,
    applied_dir: `generations/applied-${id}-0`,
    expected_invocation_json: '{"composeFileOrder":[],"projectName":null,"projectDirectory":null,"envFileOrder":[]}',
    materialization_fingerprint: 'a'.repeat(64),
    validation_ok: 1,
    plan_blocked: 0,
    change_plan_fingerprint: null,
    operation_id: `op-${id}`,
    trigger: 'manual',
    actor: 'tester',
    previous_generation_id: null,
    redacted_limitations_json: '[]',
    created_at: 1,
  };
}
