import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
  buildMemoryDelta,
  writeDeltaFile,
  readDeltaFile,
  MEMORY_DELTA_FILE,
  DELTA_LIMITS,
} from './memory-delta.js';
import { WorkspaceMemory, type LineageEntry } from './workspace-memory.js';
import { prefixWorkspacePath } from './path-normalize.js';

function makeChildMemoryWithInheritance(parentJobId: string): WorkspaceMemory {
  const memory = new WorkspaceMemory();
  // Simulate having already absorbed a parent handoff:
  memory.applyHandoff({
    facts: [{
      claim: 'parent fact A', confidence: 'high', evidencePaths: [], evidenceUrls: [],
      observedAt: '2026-05-02T00:00:00Z', portability: 'portable', evidenceKind: 'none', lineage: [],
    }],
    decisions: [],
    openQuestions: [],
    doNotRepeat: [],
    crossingEntry: { jobId: parentJobId, workspaceRelative: '../..', status: 'success', deltaId: 'h-1' },
    sourceMovement: 'inherited:handoff',
  });
  // Now the child observes its own facts:
  memory.addFact({ claim: 'child found Z', evidencePaths: ['output/z.ts'], confidence: 'high', sourceMovement: 'investigate' });
  memory.addFact({ claim: 'API result is W', evidenceUrls: ['https://api.test/w'], sourceMovement: 'investigate' });
  memory.addOpenQuestion({ question: 'why is Z slow?', sourceMovement: 'investigate' });
  return memory;
}

describe('buildMemoryDelta', () => {
  it('drops inherited (lineage-tagged-with-parent) facts to avoid loops', () => {
    const memory = makeChildMemoryWithInheritance('parent-1');
    const delta = buildMemoryDelta({
      snapshot: memory.snapshot(),
      childJobId: 'child-1',
      childWorkspaceRelative: 'subtasks/1',
      childStatus: 'success',
      partial: false,
      deltaId: 'd-1',
      parentJobId: 'parent-1',
    });
    expect(delta.facts.map((f) => f.claim).sort()).toEqual(['API result is W', 'child found Z']);
  });

  it('preserves all facts when no parentJobId given (top-level run)', () => {
    const memory = makeChildMemoryWithInheritance('parent-1');
    const delta = buildMemoryDelta({
      snapshot: memory.snapshot(),
      childJobId: 'child-1',
      childWorkspaceRelative: 'subtasks/1',
      childStatus: 'success',
      partial: false,
      deltaId: 'd-1',
      // no parentJobId
    });
    expect(delta.facts).toHaveLength(3);
  });

  it('records partial=true for aborted-with-explicit-update', () => {
    const memory = new WorkspaceMemory();
    memory.addFact({ claim: 'partial finding', sourceMovement: 'investigate' });
    const delta = buildMemoryDelta({
      snapshot: memory.snapshot(),
      childJobId: 'child-1', childWorkspaceRelative: 'subtasks/1',
      childStatus: 'aborted', partial: true, deltaId: 'd-1',
    });
    expect(delta.partial).toBe(true);
    expect(delta.childStatus).toBe('aborted');
  });

  it('truncates facts beyond DELTA_LIMITS.facts and reports the count', () => {
    const memory = new WorkspaceMemory();
    for (let i = 0; i < DELTA_LIMITS.facts + 7; i++) {
      memory.addFact({ claim: `fact ${i}`, sourceMovement: 'm' });
    }
    const delta = buildMemoryDelta({
      snapshot: memory.snapshot(),
      childJobId: 'c', childWorkspaceRelative: 'subtasks/1',
      childStatus: 'success', partial: false, deltaId: 'd',
    });
    expect(delta.facts).toHaveLength(DELTA_LIMITS.facts);
    expect(delta.truncated?.facts).toBe(7);
  });
});

describe('writeDeltaFile + readDeltaFile', () => {
  let workspace: string;

  beforeEach(() => {
    workspace = mkdtempSync(join(tmpdir(), 'phase5-delta-'));
    mkdirSync(join(workspace, 'output'), { recursive: true });
  });

  afterEach(() => {
    rmSync(workspace, { recursive: true, force: true });
  });

  it('round-trips a delta through the filesystem', () => {
    const memory = makeChildMemoryWithInheritance('parent-1');
    const delta = buildMemoryDelta({
      snapshot: memory.snapshot(),
      childJobId: 'child-1', childWorkspaceRelative: 'subtasks/1',
      childStatus: 'success', partial: false, deltaId: 'd-1',
      parentJobId: 'parent-1',
    });
    writeDeltaFile(workspace, delta);
    expect(existsSync(join(workspace, MEMORY_DELTA_FILE))).toBe(true);
    const loaded = readDeltaFile(workspace);
    expect(loaded?.deltaId).toBe('d-1');
    expect(loaded?.childStatus).toBe('success');
  });

  it('returns null for missing file', () => {
    expect(readDeltaFile(workspace)).toBeNull();
  });

  it('returns null for corrupt JSON', () => {
    writeFileSync(join(workspace, MEMORY_DELTA_FILE), '{not json', 'utf-8');
    expect(readDeltaFile(workspace)).toBeNull();
  });

  it('returns null for wrong version', () => {
    writeFileSync(
      join(workspace, MEMORY_DELTA_FILE),
      JSON.stringify({ version: 99, deltaId: 'd', childJobId: 'c', childWorkspaceRelative: 's', childStatus: 'success', partial: false, createdAt: '', facts: [], decisions: [], openQuestions: [], doNotRepeat: [] }),
      'utf-8',
    );
    expect(readDeltaFile(workspace)).toBeNull();
  });
});

describe('WorkspaceMemory.absorbDelta (parent side)', () => {
  it('absorbs a delta once and is idempotent on re-absorb', () => {
    const parentMemory = new WorkspaceMemory();
    const childMemory = makeChildMemoryWithInheritance('parent-1');
    const delta = buildMemoryDelta({
      snapshot: childMemory.snapshot(),
      childJobId: 'child-1', childWorkspaceRelative: 'subtasks/1',
      childStatus: 'success', partial: false, deltaId: 'd-1',
      parentJobId: 'parent-1',
    });
    const crossing: LineageEntry = {
      jobId: 'child-1', workspaceRelative: 'subtasks/1', status: 'success', deltaId: 'd-1',
    };
    const rewritePath = (p: string): string => prefixWorkspacePath('subtasks/1', p);

    const first = parentMemory.absorbDelta({
      deltaId: 'd-1',
      facts: delta.facts,
      decisions: delta.decisions,
      openQuestions: delta.openQuestions,
      doNotRepeat: delta.doNotRepeat,
      crossingEntry: crossing,
      rewritePath,
      sourceMovement: 'inherited:delta',
    });
    expect(first.kind).toBe('merged');
    if (first.kind !== 'merged') return;
    expect(first.counts.factsAdded).toBe(2);

    const second = parentMemory.absorbDelta({
      deltaId: 'd-1',
      facts: delta.facts,
      decisions: delta.decisions,
      openQuestions: delta.openQuestions,
      doNotRepeat: delta.doNotRepeat,
      crossingEntry: crossing,
      rewritePath,
      sourceMovement: 'inherited:delta',
    });
    expect(second.kind).toBe('skipped');
    // Memory size unchanged after second absorb.
    expect(parentMemory.size().facts).toBe(2);
  });

  it('rewrites evidencePaths with the subtask prefix and forces workspace_local', () => {
    const parentMemory = new WorkspaceMemory();
    const childMemory = new WorkspaceMemory();
    childMemory.addFact({ claim: 'X', evidencePaths: ['output/foo.ts'], evidenceUrls: ['https://e.com/a'], sourceMovement: 'm' });
    childMemory.addFact({ claim: 'Y', evidenceUrls: ['https://e.com/b'], sourceMovement: 'm' });

    const delta = buildMemoryDelta({
      snapshot: childMemory.snapshot(),
      childJobId: 'c', childWorkspaceRelative: 'subtasks/3',
      childStatus: 'success', partial: false, deltaId: 'd-1',
    });
    const rewritePath = (p: string): string => prefixWorkspacePath('subtasks/3', p);
    parentMemory.absorbDelta({
      deltaId: 'd-1',
      facts: delta.facts,
      decisions: delta.decisions,
      openQuestions: delta.openQuestions,
      doNotRepeat: delta.doNotRepeat,
      crossingEntry: { jobId: 'c', workspaceRelative: 'subtasks/3', status: 'success', deltaId: 'd-1' },
      rewritePath,
      sourceMovement: 'inherited:delta',
    });

    const snap = parentMemory.snapshot();
    const factX = snap.facts.find((f) => f.claim === 'X');
    expect(factX?.evidencePaths).toEqual(['subtasks/3/output/foo.ts']);
    // Codex policy: parent absorb forces workspace_local even if the
    // child marked it portable. Re-verification is the parent's job.
    expect(factX?.portability).toBe('workspace_local');
    expect(factX?.evidenceUrls).toEqual(['https://e.com/a']);

    const factY = snap.facts.find((f) => f.claim === 'Y');
    // No paths in Y → evidenceKind 'url' on the parent side too.
    expect(factY?.portability).toBe('workspace_local'); // forced
    expect(factY?.evidenceKind).toBe('url');
  });

  it('merges identical claims by union-ing evidence rather than duplicating', () => {
    const parentMemory = new WorkspaceMemory();
    parentMemory.addFact({ claim: 'shared truth', evidencePaths: ['parent.ts'], sourceMovement: 'parent' });

    const childMemory = new WorkspaceMemory();
    childMemory.addFact({ claim: 'shared truth', evidencePaths: ['output/child.ts'], sourceMovement: 'child' });
    const delta = buildMemoryDelta({
      snapshot: childMemory.snapshot(),
      childJobId: 'c', childWorkspaceRelative: 'subtasks/2',
      childStatus: 'success', partial: false, deltaId: 'd-1',
    });
    const result = parentMemory.absorbDelta({
      deltaId: 'd-1',
      facts: delta.facts,
      decisions: delta.decisions,
      openQuestions: delta.openQuestions,
      doNotRepeat: delta.doNotRepeat,
      crossingEntry: { jobId: 'c', workspaceRelative: 'subtasks/2', status: 'success', deltaId: 'd-1' },
      rewritePath: (p): string => prefixWorkspacePath('subtasks/2', p),
      sourceMovement: 'inherited:delta',
    });
    expect(result.kind).toBe('merged');
    if (result.kind !== 'merged') return;
    expect(result.counts.factsMerged).toBe(1);
    expect(result.counts.factsAdded).toBe(0);

    const snap = parentMemory.snapshot();
    expect(snap.facts).toHaveLength(1);
    expect(snap.facts[0]!.evidencePaths.sort()).toEqual(['parent.ts', 'subtasks/2/output/child.ts']);
  });

  it('drops paths that fail normalization (traversal etc.) but keeps the fact', () => {
    const parentMemory = new WorkspaceMemory();
    const result = parentMemory.absorbDelta({
      deltaId: 'd-1',
      facts: [{
        claim: 'fact with bad path', confidence: 'medium',
        evidencePaths: ['../escape.ts', 'output/ok.ts'], evidenceUrls: [],
        observedAt: '2026-05-02T00:00:00Z',
        portability: 'workspace_local', evidenceKind: 'local_path', lineage: [],
      }],
      decisions: [],
      openQuestions: [],
      doNotRepeat: [],
      crossingEntry: { jobId: 'c', workspaceRelative: 'subtasks/1', status: 'success', deltaId: 'd-1' },
      rewritePath: (p): string => prefixWorkspacePath('subtasks/1', p),
      sourceMovement: 'inherited:delta',
    });
    expect(result.kind).toBe('merged');
    if (result.kind !== 'merged') return;
    expect(result.counts.factsAdded).toBe(1);
    expect(result.counts.pathsDropped).toBe(1);
    expect(parentMemory.snapshot().facts[0]!.evidencePaths).toEqual(['subtasks/1/output/ok.ts']);
  });
});

describe('hasAbsorbedDelta + restoreAbsorbedDeltaIds', () => {
  it('skips re-absorb after restore from persistence', () => {
    const memory = new WorkspaceMemory();
    memory.restoreAbsorbedDeltaIds(['d-1', 'd-2']);
    expect(memory.hasAbsorbedDelta('d-1')).toBe(true);
    expect(memory.hasAbsorbedDelta('d-2')).toBe(true);
    expect(memory.hasAbsorbedDelta('d-3')).toBe(false);

    const result = memory.absorbDelta({
      deltaId: 'd-1',
      facts: [], decisions: [], openQuestions: [], doNotRepeat: [],
      crossingEntry: { jobId: 'c', workspaceRelative: 'subtasks/1', status: 'success', deltaId: 'd-1' },
      rewritePath: (p): string => p,
      sourceMovement: 'inherited:delta',
    });
    expect(result.kind).toBe('skipped');
  });
});
