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 {
  buildMemoryHandoff,
  writeHandoffFile,
  readHandoffFile,
  MEMORY_HANDOFF_FILE,
  HANDOFF_LIMITS,
} from './memory-handoff.js';
import { WorkspaceMemory, renderMemorySnapshot, type LineageEntry } from './workspace-memory.js';

function makeSnapshotMemory(): WorkspaceMemory {
  const memory = new WorkspaceMemory();
  memory.addFact({ claim: 'X uses Y', evidencePaths: ['foo.ts'], confidence: 'high', sourceMovement: 'investigate' });
  memory.addFact({ claim: 'API returns Z', evidenceUrls: ['https://example.com/api'], confidence: 'medium', sourceMovement: 'investigate' });
  memory.addFact({ claim: 'just an observation', sourceMovement: 'investigate' });
  memory.addDecision({ text: 'choose A', evidencePaths: ['foo.ts'], sourceMovement: 'plan' });
  memory.addOpenQuestion({ question: 'is foo aware of bar?', sourceMovement: 'plan' });
  memory.addDoNotRepeat('do not re-read foo.ts');
  return memory;
}

describe('buildMemoryHandoff', () => {
  it('serializes a snapshot into a v1 handoff payload', () => {
    const memory = makeSnapshotMemory();
    const handoff = buildMemoryHandoff({
      snapshot: memory.snapshot(),
      parentJobId: 'job-1',
      parentWorkspaceRelative: '../..',
      handoffId: 'h-1',
      now: '2026-05-02T00:00:00.000Z',
    });
    expect(handoff.version).toBe(1);
    expect(handoff.handoffId).toBe('h-1');
    expect(handoff.parentJobId).toBe('job-1');
    expect(handoff.facts).toHaveLength(3);
    expect(handoff.decisions).toHaveLength(1);
    expect(handoff.openQuestions).toHaveLength(1);
    expect(handoff.doNotRepeat).toEqual(['do not re-read foo.ts']);
  });

  it('preserves evidence kind and portability per fact', () => {
    const memory = makeSnapshotMemory();
    const handoff = buildMemoryHandoff({
      snapshot: memory.snapshot(),
      parentJobId: 'job-1',
      parentWorkspaceRelative: '../..',
      handoffId: 'h-1',
    });
    const byClaimKind = Object.fromEntries(handoff.facts.map((f) => [f.claim, [f.evidenceKind, f.portability]]));
    expect(byClaimKind['X uses Y']).toEqual(['local_path', 'workspace_local']);
    expect(byClaimKind['API returns Z']).toEqual(['url', 'portable']);
    expect(byClaimKind['just an observation']).toEqual(['none', 'portable']);
  });

  it('filters out facts/decisions whose claim text matches sensitive keywords', () => {
    const memory = new WorkspaceMemory();
    memory.addFact({ claim: 'normal fact', sourceMovement: 'm' });
    memory.addFact({ claim: 'admin password is hunter2', sourceMovement: 'm' });
    memory.addFact({ claim: 'API_KEY for service is X', sourceMovement: 'm' });
    memory.addDecision({ text: 'rotate the auth token quarterly', sourceMovement: 'm' });
    memory.addDecision({ text: 'pick option A', sourceMovement: 'm' });

    const handoff = buildMemoryHandoff({
      snapshot: memory.snapshot(),
      parentJobId: 'job-1',
      parentWorkspaceRelative: '../..',
      handoffId: 'h-1',
    });
    expect(handoff.facts.map((f) => f.claim)).toEqual(['normal fact']);
    expect(handoff.decisions.map((d) => d.text)).toEqual(['pick option A']);
    expect(handoff.filteredSensitive).toEqual({ facts: 2, decisions: 1 });
  });

  it('truncates per-category beyond HANDOFF_LIMITS', () => {
    const memory = new WorkspaceMemory();
    for (let i = 0; i < HANDOFF_LIMITS.facts + 5; i++) {
      memory.addFact({ claim: `fact ${i}`, sourceMovement: 'm' });
    }
    const handoff = buildMemoryHandoff({
      snapshot: memory.snapshot(),
      parentJobId: 'job-1',
      parentWorkspaceRelative: '../..',
      handoffId: 'h-1',
    });
    expect(handoff.facts).toHaveLength(HANDOFF_LIMITS.facts);
    expect(handoff.truncated?.facts).toBe(5);
  });
});

describe('writeHandoffFile + readHandoffFile', () => {
  let workspace: string;

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

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

  it('round-trips a handoff through the filesystem', () => {
    const memory = makeSnapshotMemory();
    const handoff = buildMemoryHandoff({
      snapshot: memory.snapshot(),
      parentJobId: 'job-1',
      parentWorkspaceRelative: '../..',
      handoffId: 'h-1',
    });
    writeHandoffFile(workspace, handoff);
    expect(existsSync(join(workspace, MEMORY_HANDOFF_FILE))).toBe(true);

    const loaded = readHandoffFile(workspace);
    expect(loaded?.handoffId).toBe('h-1');
    expect(loaded?.facts).toHaveLength(3);
  });

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

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

  it('returns null for wrong version', () => {
    writeFileSync(
      join(workspace, MEMORY_HANDOFF_FILE),
      JSON.stringify({ version: 999, handoffId: 'x', parentJobId: 'p', parentWorkspaceRelative: '../..', createdAt: '', facts: [], decisions: [], openQuestions: [], doNotRepeat: [] }),
      'utf-8',
    );
    expect(readHandoffFile(workspace)).toBeNull();
  });
});

describe('WorkspaceMemory.applyHandoff (Phase 5 child side)', () => {
  it('absorbs a handoff and tags every fact with lineage and preserved portability', () => {
    const parentMemory = makeSnapshotMemory();
    const handoff = buildMemoryHandoff({
      snapshot: parentMemory.snapshot(),
      parentJobId: 'job-parent',
      parentWorkspaceRelative: '../..',
      handoffId: 'h-1',
    });

    const childMemory = new WorkspaceMemory();
    const crossing: LineageEntry = {
      jobId: 'job-parent',
      workspaceRelative: '../..',
      status: 'success',
      deltaId: 'h-1',
    };
    const result = childMemory.applyHandoff({
      facts: handoff.facts,
      decisions: handoff.decisions,
      openQuestions: handoff.openQuestions,
      doNotRepeat: handoff.doNotRepeat,
      crossingEntry: crossing,
      sourceMovement: 'inherited:handoff',
    });

    expect(result.factsAdded).toBe(3);
    const snap = childMemory.snapshot();
    for (const f of snap.facts) {
      expect(f.lineage).toHaveLength(1);
      expect(f.lineage[0]!.jobId).toBe('job-parent');
    }
    // Portability is preserved across the boundary; never re-promoted.
    const byClaim = Object.fromEntries(snap.facts.map((f) => [f.claim, f.portability]));
    expect(byClaim['X uses Y']).toBe('workspace_local');
    expect(byClaim['API returns Z']).toBe('portable');
    expect(byClaim['just an observation']).toBe('portable');
  });

  it('renders inherited workspace_local facts with [要再検証] and [他 workspace 由来]', () => {
    const parentMemory = makeSnapshotMemory();
    const handoff = buildMemoryHandoff({
      snapshot: parentMemory.snapshot(),
      parentJobId: 'job-parent',
      parentWorkspaceRelative: '../..',
      handoffId: 'h-1',
    });
    const childMemory = new WorkspaceMemory();
    childMemory.applyHandoff({
      facts: handoff.facts,
      decisions: handoff.decisions,
      openQuestions: handoff.openQuestions,
      doNotRepeat: handoff.doNotRepeat,
      crossingEntry: { jobId: 'job-parent', workspaceRelative: '../..', status: 'success', deltaId: 'h-1' },
      sourceMovement: 'inherited:handoff',
    });
    const out = renderMemorySnapshot(childMemory.snapshot());
    // workspace_local fact gets the 要再検証 tag plus the lineage cue.
    expect(out).toContain('X uses Y');
    expect(out).toContain('要再検証');
    expect(out).toContain('他 workspace 由来');
    // portable URL fact does NOT get 要再検証.
    const apiFactLine = out.split('\n').find((line) => line.includes('API returns Z')) ?? '';
    expect(apiFactLine).not.toContain('要再検証');
  });
});
