// SF Extension — workflow-manifest unit tests
// Tests writeManifest, readManifest, snapshotState, bootstrapFromManifest.

import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
  openDatabase,
  closeDatabase,
  insertMilestone,
  insertSlice,
  insertTask,
  _getAdapter,
} from '../sf-db.ts';
import {
  writeManifest,
  readManifest,
  snapshotState,
  bootstrapFromManifest,
} from '../workflow-manifest.ts';

function tempDir(): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'sf-manifest-'));
}

function tempDbPath(base: string): string {
  return path.join(base, 'test.db');
}

function cleanupDir(dirPath: string): void {
  try { fs.rmSync(dirPath, { recursive: true, force: true }); } catch { /* best effort */ }
}

// ─── readManifest: no file ────────────────────────────────────────────────

test('workflow-manifest: readManifest returns null when file does not exist', () => {
  const base = tempDir();
  try {
    const result = readManifest(base);
    assert.strictEqual(result, null);
  } finally {
    cleanupDir(base);
  }
});

// ─── writeManifest + readManifest round-trip ─────────────────────────────

test('workflow-manifest: writeManifest creates state-manifest.json with version 1', () => {
  const base = tempDir();
  openDatabase(tempDbPath(base));
  try {
    writeManifest(base);
    const manifestPath = path.join(base, '.sf', 'state-manifest.json');
    assert.ok(fs.existsSync(manifestPath), 'state-manifest.json should exist');
    const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
    assert.strictEqual(raw.version, 1);
  } finally {
    closeDatabase();
    cleanupDir(base);
  }
});

test('workflow-manifest: readManifest parses manifest written by writeManifest', () => {
  const base = tempDir();
  openDatabase(tempDbPath(base));
  try {
    writeManifest(base);
    const manifest = readManifest(base);
    assert.ok(manifest !== null);
    assert.strictEqual(manifest!.version, 1);
    assert.ok(typeof manifest!.exported_at === 'string');
    assert.ok(Array.isArray(manifest!.milestones));
    assert.ok(Array.isArray(manifest!.slices));
    assert.ok(Array.isArray(manifest!.tasks));
    assert.ok(Array.isArray(manifest!.decisions));
    assert.ok(Array.isArray(manifest!.verification_evidence));
  } finally {
    closeDatabase();
    cleanupDir(base);
  }
});

// ─── snapshotState: captures DB rows ─────────────────────────────────────

test('workflow-manifest: snapshotState includes inserted milestone', () => {
  const base = tempDir();
  openDatabase(tempDbPath(base));
  try {
    insertMilestone({ id: 'M001', title: 'Auth Milestone' });
    const snap = snapshotState();
    assert.strictEqual(snap.version, 1);
    const m = snap.milestones.find((r) => r.id === 'M001');
    assert.ok(m !== undefined, 'M001 should appear in snapshot');
    assert.strictEqual(m!.title, 'Auth Milestone');
  } finally {
    closeDatabase();
    cleanupDir(base);
  }
});

test('workflow-manifest: snapshotState captures tasks', () => {
  const base = tempDir();
  openDatabase(tempDbPath(base));
  try {
    insertMilestone({ id: 'M001' });
    insertSlice({ id: 'S01', milestoneId: 'M001' });
    insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Do thing', status: 'complete' });
    const snap = snapshotState();
    const t = snap.tasks.find((r) => r.id === 'T01');
    assert.ok(t !== undefined, 'T01 should appear in snapshot');
    assert.strictEqual(t!.status, 'complete');
  } finally {
    closeDatabase();
    cleanupDir(base);
  }
});

test('workflow-manifest: snapshotState preserves planning ceremony fields', () => {
  const base = tempDir();
  openDatabase(tempDbPath(base));
  try {
    insertMilestone({
      id: 'M001',
      title: 'Ceremony Milestone',
      planning: {
        vision: 'Keep planning context intact.',
        visionMeeting: {
          trigger: 'Roadmap needs weighted synthesis.',
          pm: 'Keep the milestone tight.',
          userAdvocate: 'Users need stable planning state.',
          customerPanel: 'Operators want fidelity.',
          business: 'Planning should survive recovery.',
          researcher: 'Comparable systems persist review context.',
          deliveryLead: 'Avoid losing context between turns.',
          partner: 'This is enough to move forward.',
          combatant: 'Do not drop the discussion state on restore.',
          architect: 'Persist the meeting in the same state snapshot.',
          moderator: 'Weighted synthesis says this is ready to plan.',
          weightedSynthesis: 'Keep the meeting state with the roadmap.',
          confidenceByArea: '- sequencing: high',
          recommendedRoute: 'planning',
        },
      },
    });
    insertSlice({
      id: 'S01',
      milestoneId: 'M001',
      title: 'Ceremony Slice',
      planning: {
        goal: 'Keep slice planning context intact.',
        adversarialReview: {
          partner: 'The slice plan is grounded enough.',
          combatant: 'The missing integration point is manifest restore.',
          architect: 'Persist both review and meeting fields together.',
        },
        planningMeeting: {
          trigger: 'Ambiguity remained after adversarial review.',
          pm: 'Keep the execution increment narrow.',
          researcher: 'The current code drops planning state in export/restore.',
          partner: 'The current plan is close.',
          combatant: 'Not if restore loses the meeting.',
          architect: 'Fix manifest and reconcile paths together.',
          moderator: 'Do the persistence fix before calling this execution-ready.',
          recommendedRoute: 'planning',
          confidenceSummary: 'High confidence after export/restore proof.',
        },
      },
    });
    insertTask({
      id: 'T01',
      sliceId: 'S01',
      milestoneId: 'M001',
      title: 'Preserve full plan',
      status: 'pending',
    });
    _getAdapter()!.prepare(`UPDATE tasks SET full_plan_md = ? WHERE milestone_id = ? AND slice_id = ? AND id = ?`)
      .run('# T01 Plan\n\nKeep the task plan body.', 'M001', 'S01', 'T01');

    const snap = snapshotState();
    assert.equal(snap.milestones[0]?.vision_meeting?.recommendedRoute, 'planning');
    assert.match(snap.milestones[0]?.vision_meeting?.customerPanel ?? '', /Operators want fidelity/);
    assert.match(snap.slices[0]?.adversarial_partner ?? '', /grounded enough/);
    assert.equal(snap.slices[0]?.planning_meeting?.recommendedRoute, 'planning');
    assert.match(snap.tasks[0]?.full_plan_md ?? '', /Keep the task plan body/);
  } finally {
    closeDatabase();
    cleanupDir(base);
  }
});

// ─── bootstrapFromManifest ────────────────────────────────────────────────

test('workflow-manifest: bootstrapFromManifest returns false when no manifest file', () => {
  const base = tempDir();
  openDatabase(tempDbPath(base));
  try {
    const result = bootstrapFromManifest(base);
    assert.strictEqual(result, false);
  } finally {
    closeDatabase();
    cleanupDir(base);
  }
});

test('workflow-manifest: bootstrapFromManifest restores DB from manifest (round-trip)', () => {
  const base = tempDir();
  openDatabase(tempDbPath(base));
  try {
    // Insert data and write manifest
    insertMilestone({ id: 'M001', title: 'Restored Milestone' });
    insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Restored Slice' });
    insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Restored Task', status: 'complete' });
    writeManifest(base);
    closeDatabase();

    // Open a fresh DB and bootstrap from manifest
    const newDbPath = path.join(base, 'new.db');
    openDatabase(newDbPath);
    const result = bootstrapFromManifest(base);
    assert.strictEqual(result, true, 'bootstrapFromManifest should return true');

    // Verify restored state
    const snap = snapshotState();
    const m = snap.milestones.find((r) => r.id === 'M001');
    assert.ok(m !== undefined, 'M001 should be restored');
    assert.strictEqual(m!.title, 'Restored Milestone');

    const s = snap.slices.find((r) => r.id === 'S01');
    assert.ok(s !== undefined, 'S01 should be restored');

    const t = snap.tasks.find((r) => r.id === 'T01');
    assert.ok(t !== undefined, 'T01 should be restored');
    assert.strictEqual(t!.status, 'complete');
  } finally {
    closeDatabase();
    cleanupDir(base);
  }
});

test('workflow-manifest: bootstrapFromManifest restores planning ceremony fields', () => {
  const base = tempDir();
  openDatabase(tempDbPath(base));
  try {
    insertMilestone({
      id: 'M001',
      title: 'Restored Ceremony Milestone',
      planning: {
        vision: 'Round-trip the planning context.',
        visionMeeting: {
          trigger: 'Need roadmap agreement.',
          pm: 'Keep the roadmap focused.',
          userAdvocate: 'Do not lose planning context.',
          customerPanel: 'Advanced users expect nuance.',
          business: 'Recovery should preserve planning fidelity.',
          researcher: 'The export/restore path is part of the workflow contract.',
          deliveryLead: 'Keep the fix narrow and testable.',
          partner: 'The meeting is specific enough.',
          combatant: 'It is useless if restore drops it.',
          architect: 'Round-trip the meeting JSON as part of milestone restore.',
          moderator: 'Planning can proceed once restore is lossless.',
          weightedSynthesis: 'Persist the whole meeting.',
          confidenceByArea: '- restore fidelity: high',
          recommendedRoute: 'planning',
        },
      },
    });
    insertSlice({
      id: 'S01',
      milestoneId: 'M001',
      title: 'Restored Ceremony Slice',
      planning: {
        goal: 'Round-trip slice planning context.',
        adversarialReview: {
          partner: 'The slice is focused enough.',
          combatant: 'Restore has to keep the review body.',
          architect: 'Manifest loss here would break replans and worktrees.',
        },
        planningMeeting: {
          trigger: 'There was still ambiguity.',
          pm: 'Cut to the restore boundary.',
          researcher: 'The DB stores the meeting already.',
          partner: 'The change is local.',
          combatant: 'Only if export and restore both carry it.',
          architect: 'Test both directions.',
          moderator: 'Proceed after round-trip proof.',
          recommendedRoute: 'planning',
          confidenceSummary: 'High after round-trip.',
        },
      },
    });
    insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Restore it', status: 'complete' });
    _getAdapter()!.prepare(`UPDATE tasks SET full_plan_md = ? WHERE milestone_id = ? AND slice_id = ? AND id = ?`)
      .run('# T01 Plan\n\nRestored full plan.', 'M001', 'S01', 'T01');
    writeManifest(base);
    closeDatabase();

    openDatabase(path.join(base, 'roundtrip.db'));
    const result = bootstrapFromManifest(base);
    assert.equal(result, true);

    const snap = snapshotState();
    assert.equal(snap.milestones[0]?.vision_meeting?.recommendedRoute, 'planning');
    assert.match(snap.milestones[0]?.vision_meeting?.researcher ?? '', /workflow contract/);
    assert.match(snap.slices[0]?.adversarial_architect ?? '', /worktrees/);
    assert.equal(snap.slices[0]?.planning_meeting?.recommendedRoute, 'planning');
    assert.match(snap.tasks[0]?.full_plan_md ?? '', /Restored full plan/);
  } finally {
    closeDatabase();
    cleanupDir(base);
  }
});

// ─── snapshotState: numeric column coercion (#2962) ─────────────────────

test('workflow-manifest: snapshotState coerces string placeholders in numeric columns to null (#2962)', () => {
  const base = tempDir();
  openDatabase(tempDbPath(base));
  try {
    // Set up prerequisite rows
    insertMilestone({ id: 'M001' });
    insertSlice({ id: 'S01', milestoneId: 'M001' });
    insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Task', status: 'complete' });

    // Insert verification_evidence with string placeholders in numeric columns
    // This simulates what happens after schema migrations or manual inserts
    const db = _getAdapter()!;
    db.prepare(
      `INSERT INTO verification_evidence (task_id, slice_id, milestone_id, command, exit_code, verdict, duration_ms, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
    ).run('T01', 'S01', 'M001', 'npm test', '-', 'pass', '-', new Date().toISOString());

    // snapshotState should coerce "-" to null for numeric columns
    const snap = snapshotState();
    const ev = snap.verification_evidence[0];
    assert.strictEqual(ev.exit_code, null, 'exit_code "-" should be coerced to null');
    assert.strictEqual(ev.duration_ms, null, 'duration_ms "-" should be coerced to null');

    // Round-trip through JSON should not throw
    const json = JSON.stringify(snap, null, 2);
    const reparsed = JSON.parse(json);
    assert.strictEqual(reparsed.verification_evidence[0].exit_code, null);
    assert.strictEqual(reparsed.verification_evidence[0].duration_ms, null);
  } finally {
    closeDatabase();
    cleanupDir(base);
  }
});

test('workflow-manifest: snapshotState coerces empty string and N/A in numeric columns (#2962)', () => {
  const base = tempDir();
  openDatabase(tempDbPath(base));
  try {
    insertMilestone({ id: 'M001' });
    insertSlice({ id: 'S01', milestoneId: 'M001' });
    insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Task', status: 'complete' });

    const db = _getAdapter()!;
    db.prepare(
      `INSERT INTO verification_evidence (task_id, slice_id, milestone_id, command, exit_code, verdict, duration_ms, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
    ).run('T01', 'S01', 'M001', 'npm test', 'N/A', 'pass', '', new Date().toISOString());

    const snap = snapshotState();
    const ev = snap.verification_evidence[0];
    assert.strictEqual(ev.exit_code, null, 'exit_code "N/A" should be coerced to null');
    assert.strictEqual(ev.duration_ms, null, 'duration_ms "" should be coerced to null');
  } finally {
    closeDatabase();
    cleanupDir(base);
  }
});

test('workflow-manifest: snapshotState coerces string placeholders in sequence columns (#2962)', () => {
  const base = tempDir();
  openDatabase(tempDbPath(base));
  try {
    insertMilestone({ id: 'M001' });

    // Insert a slice with a string sequence via raw SQL
    const db = _getAdapter()!;
    db.prepare(
      `INSERT INTO slices (milestone_id, id, title, status, risk, depends, demo, created_at, sequence)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
    ).run('M001', 'S01', 'Test Slice', 'planned', 'low', '[]', '', new Date().toISOString(), '-');

    db.prepare(
      `INSERT INTO tasks (milestone_id, slice_id, id, title, status, sequence)
       VALUES (?, ?, ?, ?, ?, ?)`,
    ).run('M001', 'S01', 'T01', 'Test Task', 'planned', 'N/A');

    const snap = snapshotState();
    assert.strictEqual(snap.slices[0].sequence, 0, 'slice sequence "-" should be coerced to 0');
    assert.strictEqual(snap.tasks[0].sequence, 0, 'task sequence "N/A" should be coerced to 0');

    // JSON round-trip must not throw
    const json = JSON.stringify(snap, null, 2);
    assert.doesNotThrow(() => JSON.parse(json));
  } finally {
    closeDatabase();
    cleanupDir(base);
  }
});

// ─── readManifest: version check ─────────────────────────────────────────

test('workflow-manifest: readManifest throws on unsupported version', () => {
  const base = tempDir();
  try {
    fs.mkdirSync(path.join(base, '.sf'), { recursive: true });
    fs.writeFileSync(
      path.join(base, '.sf', 'state-manifest.json'),
      JSON.stringify({ version: 99, exported_at: '', milestones: [], slices: [], tasks: [], decisions: [], verification_evidence: [] }),
    );
    assert.throws(
      () => readManifest(base),
      /Unsupported manifest version/,
      'should throw on version mismatch',
    );
  } finally {
    cleanupDir(base);
  }
});
