// src/engine/reflection/applier.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { applyReflection, type ApplierDeps } from './applier.js';
import { upsertMemoryEntry, readMemoryEntry } from '../../user-folder/memory.js';
import { bodyRevision } from './revisions.js';
import { Repository } from '../../db/repository.js';
import { PieceCatalog } from '../piece-catalog.js';
import type { ReflectionInput, ReflectionResult } from './types.js';

// ── Test fixtures ─────────────────────────────────────────────────────────────

const USER_ID = 'u-applier-test';
const MAX_BODY = 8192;

/**
 * Build ApplierDeps wired with a real Repository (in-memory SQLite) and
 * PieceCatalog.  builtinDir is created inside tmpDir so silentFork can find
 * builtin YAML files when needed.
 */
function makeDeps(tmpDir: string): { deps: ApplierDeps; repo: Repository; catalog: PieceCatalog; builtinDir: string } {
  const builtinDir = join(tmpDir, 'pieces');
  mkdirSync(builtinDir, { recursive: true });
  const repo = new Repository(join(tmpDir, 'db.sqlite'));
  const catalog = new PieceCatalog(builtinDir, tmpDir);
  const deps: ApplierDeps = {
    dataDir: tmpDir,
    maxBodyBytes: MAX_BODY,
    repo,
    catalog,
    builtinDir,
    cooldownHours: 24,
  };
  return { deps, repo, catalog, builtinDir };
}

function makeInput(
  overrides: Partial<ReflectionInput> = {},
): ReflectionInput {
  return {
    originalJobId: 'j-001',
    userId: USER_ID,
    pieceName: 'chat',
    pieceSource: 'builtin',
    outcome: 'succeeded',
    taskTitle: 'test task',
    taskBody: 'do the thing',
    activityLogSummary: '',
    postCompletionComments: [],
    feedback: { rating: null, comment: null, tags: [] },
    resultText: 'done',
    observedRevisions: {},
    memoryIndex: '',
    memoryEntries: [],
    pieceYaml: 'name: chat\nmovements:\n  - name: m1\n    rules: []\n',
    ...overrides,
  };
}

function makeResult(overrides: Partial<ReflectionResult> = {}): ReflectionResult {
  return {
    memory_changes: [],
    piece_changes: { should_edit: false },
    reasoning: 'test',
    ...overrides,
  };
}

/**
 * Seed an existing entry and return:
 *   - revision: bodyRevision of the stored body (after gray-matter round-trip)
 *   - storedBody: the body string as returned by readMemoryEntry (with trailing \n)
 *
 * gray-matter adds a trailing \n on serialize → readMemoryEntry returns body
 * with '\n' appended.  Use storedBody in memoryEntries fixtures so the
 * semantic validator's existing-name set is populated correctly, and use
 * revision in observedRevisions so the CAS check matches the on-disk hash.
 */
function seedEntry(
  dataDir: string,
  name: string,
  body = 'original body',
  description = 'test entry',
  type: 'user' | 'feedback' | 'project' | 'reference' = 'user',
): { revision: string; storedBody: string } {
  upsertMemoryEntry(dataDir, USER_ID, { name, type, description, body });
  // Read back the actual stored body so we can hash it consistently.
  const stored = readMemoryEntry(dataDir, USER_ID, name)!;
  return { revision: bodyRevision(stored.body), storedBody: stored.body };
}

// ── Test suite ────────────────────────────────────────────────────────────────

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

  beforeEach(() => {
    tmpDir = mkdtempSync(join(tmpdir(), 'applier-'));
  });

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

  // ── add: happy path ─────────────────────────────────────────────────────────
  it('add happy path — file is written with correct content', async () => {
    const { deps } = makeDeps(tmpDir);
    const input = makeInput();
    const result = makeResult({
      memory_changes: [
        {
          op: 'add',
          name: 'new_fact',
          type: 'user',
          description: 'a new fact',
          body: 'body of the new fact',
        },
      ],
    });

    const r = await applyReflection(deps, input, result);

    expect(r.memoryDecisions[0]?.accepted).toBe(true);
    expect(r.outcome).toBe('applied');

    const entry = readMemoryEntry(tmpDir, USER_ID, 'new_fact');
    expect(entry).not.toBeNull();
    // gray-matter appends \n on serialize round-trip; trim for content check.
    expect(entry!.body.trim()).toBe('body of the new fact');
    expect(entry!.meta.description).toBe('a new fact');
    expect(entry!.meta.type).toBe('user');
  });

  // ── update: happy path ──────────────────────────────────────────────────────
  it('update happy path with matching observedRevisions — body is replaced', async () => {
    const { deps } = makeDeps(tmpDir);
    const { revision, storedBody } = seedEntry(tmpDir, 'existing_entry', 'old body');
    const input = makeInput({
      observedRevisions: { existing_entry: revision },
      memoryEntries: [
        { name: 'existing_entry', description: 'desc', type: 'user', body: storedBody },
      ],
    });
    const result = makeResult({
      memory_changes: [
        {
          op: 'update',
          name: 'existing_entry',
          type: 'user',
          description: 'updated desc',
          body: 'new body',
          merge_target: 'existing_entry',
        },
      ],
    });

    const r = await applyReflection(deps, input, result);

    expect(r.memoryDecisions[0]?.accepted).toBe(true);
    expect(r.outcome).toBe('applied');

    const entry = readMemoryEntry(tmpDir, USER_ID, 'existing_entry');
    expect(entry!.body.trim()).toBe('new body');
  });

  // ── update: stale revision ──────────────────────────────────────────────────
  it('update with stale observedRevisions → rejected_stale_target, file unchanged', async () => {
    const { deps } = makeDeps(tmpDir);
    const { storedBody } = seedEntry(tmpDir, 'stale_entry', 'current body');
    const input = makeInput({
      // Intentionally wrong revision — simulates a concurrent update.
      observedRevisions: { stale_entry: 'deadbeef00000000000000000000000000000000' },
      memoryEntries: [
        { name: 'stale_entry', description: 'desc', type: 'user', body: storedBody },
      ],
    });
    const result = makeResult({
      memory_changes: [
        {
          op: 'update',
          name: 'stale_entry',
          type: 'user',
          description: 'stale',
          body: 'should not land',
          merge_target: 'stale_entry',
        },
      ],
    });

    const r = await applyReflection(deps, input, result);

    expect(r.memoryDecisions[0]?.accepted).toBe(false);
    expect(r.memoryDecisions[0]?.code).toBe('rejected_stale_target');
    expect(r.outcome).toBe('rejected');

    // File must remain unchanged.
    const entry = readMemoryEntry(tmpDir, USER_ID, 'stale_entry');
    expect(entry!.body.trim()).toBe('current body');
  });

  // ── merge_into: happy path ──────────────────────────────────────────────────
  it('merge_into happy path — original body preserved, new section appended', async () => {
    const { deps } = makeDeps(tmpDir);
    const { revision, storedBody } = seedEntry(tmpDir, 'target_entry', 'original content', 'entry desc', 'feedback');
    const input = makeInput({
      observedRevisions: { target_entry: revision },
      memoryEntries: [
        { name: 'target_entry', description: 'entry desc', type: 'feedback', body: storedBody },
      ],
    });
    const result = makeResult({
      memory_changes: [
        {
          op: 'merge_into',
          name: 'target_entry',
          type: 'feedback',
          description: 'entry desc',
          body: 'newly learned insight',
          merge_target: 'target_entry',
        },
      ],
    });

    const r = await applyReflection(deps, input, result);

    expect(r.memoryDecisions[0]?.accepted).toBe(true);
    expect(r.outcome).toBe('applied');

    const entry = readMemoryEntry(tmpDir, USER_ID, 'target_entry');
    expect(entry!.body).toContain('original content');
    expect(entry!.body).toContain('newly learned insight');
    expect(entry!.body).toContain('## Updated');
    // Original body must come before appended content.
    expect(entry!.body.indexOf('original content')).toBeLessThan(
      entry!.body.indexOf('newly learned insight'),
    );
  });

  // ── remove: happy path ──────────────────────────────────────────────────────
  it('remove happy path — file deleted, MEMORY.md index updated', async () => {
    const { deps } = makeDeps(tmpDir);
    const { revision, storedBody } = seedEntry(tmpDir, 'to_remove', 'body to delete', 'removable', 'reference');
    const input = makeInput({
      observedRevisions: { to_remove: revision },
      memoryEntries: [
        { name: 'to_remove', description: 'removable', type: 'reference', body: storedBody },
      ],
    });
    const result = makeResult({
      memory_changes: [
        {
          op: 'remove',
          name: 'to_remove',
          type: 'reference',
          description: 'removable',
          body: '',
          merge_target: 'to_remove',
        },
      ],
    });

    const r = await applyReflection(deps, input, result);

    expect(r.memoryDecisions[0]?.accepted).toBe(true);
    expect(r.outcome).toBe('applied');

    // The fact file should be gone (moved to trash).
    const entry = readMemoryEntry(tmpDir, USER_ID, 'to_remove');
    expect(entry).toBeNull();

    // MEMORY.md must not reference the removed entry.
    const memDir = join(tmpDir, USER_ID, 'memory');
    const indexPath = join(memDir, 'MEMORY.md');
    if (existsSync(indexPath)) {
      const indexContent = readFileSync(indexPath, 'utf-8');
      expect(indexContent).not.toContain('to_remove');
    }
  });

  // ── remove: nonexistent target ──────────────────────────────────────────────
  it('remove on nonexistent merge_target → rejected_missing_target, no side effect', async () => {
    const { deps } = makeDeps(tmpDir);
    // memoryEntries includes 'ghost' so the semantic-validator passes
    // (it checks whether merge_target exists in memoryEntries, not on disk).
    // The applier's CAS step then detects the file is absent and rejects.
    const input = makeInput({
      observedRevisions: { ghost: 'abc123abc123abc123abc123abc123abc123abc123' },
      memoryEntries: [
        { name: 'ghost', description: 'not on disk', type: 'user', body: 'body\n' },
      ],
    });
    const result = makeResult({
      memory_changes: [
        {
          op: 'remove',
          name: 'ghost',
          type: 'user',
          description: 'not on disk',
          body: '',
          merge_target: 'ghost',
        },
      ],
    });

    const r = await applyReflection(deps, input, result);

    expect(r.memoryDecisions[0]?.accepted).toBe(false);
    expect(r.memoryDecisions[0]?.code).toBe('rejected_missing_target');
    expect(r.outcome).toBe('rejected');
  });

  // ── 3-entry cap ─────────────────────────────────────────────────────────────
  it('3-entry cap: 4th valid memory_change is silently dropped', async () => {
    const { deps } = makeDeps(tmpDir);
    const input = makeInput();

    // 4 distinct adds with unique names — all semantically valid.
    const result = makeResult({
      memory_changes: [
        { op: 'add', name: 'fact_a', type: 'user', description: 'a', body: 'body a' },
        { op: 'add', name: 'fact_b', type: 'user', description: 'b', body: 'body b' },
        { op: 'add', name: 'fact_c', type: 'user', description: 'c', body: 'body c' },
        { op: 'add', name: 'fact_d', type: 'user', description: 'd', body: 'body d' },
      ],
    });

    const r = await applyReflection(deps, input, result);

    // Only 3 decisions should be present.
    expect(r.memoryDecisions).toHaveLength(3);
    // All 3 accepted.
    expect(r.memoryDecisions.every((d) => d.accepted)).toBe(true);
    expect(r.outcome).toBe('applied');

    // fact_d must NOT have been written.
    expect(readMemoryEntry(tmpDir, USER_ID, 'fact_d')).toBeNull();
  });

  // ── mixed accepted + rejected → partial ─────────────────────────────────────
  it('mixed accepted + rejected decisions → outcome === partial', async () => {
    const { deps } = makeDeps(tmpDir);
    const input = makeInput();

    const result = makeResult({
      memory_changes: [
        // Good add.
        { op: 'add', name: 'good_add', type: 'user', description: 'good', body: 'body' },
        // Bad name — rejected_bad_name.
        { op: 'add', name: '../bad', type: 'user', description: 'evil', body: 'body' },
      ],
    });

    const r = await applyReflection(deps, input, result);

    expect(r.memoryDecisions[0]?.accepted).toBe(true);
    expect(r.memoryDecisions[1]?.accepted).toBe(false);
    expect(r.outcome).toBe('partial');
  });

  // ── all rejected → outcome === rejected ─────────────────────────────────────
  it('all rejected, none applied → outcome === rejected', async () => {
    const { deps } = makeDeps(tmpDir);
    const input = makeInput();

    const result = makeResult({
      memory_changes: [
        // Both have invalid names.
        { op: 'add', name: '', type: 'user', description: 'a', body: 'b' },
        { op: 'add', name: '!invalid!', type: 'user', description: 'a', body: 'b' },
      ],
    });

    const r = await applyReflection(deps, input, result);

    expect(r.memoryDecisions.every((d) => !d.accepted)).toBe(true);
    expect(r.outcome).toBe('rejected');
  });

  // ── abstain_reason + no changes → outcome === abstained ─────────────────────
  it('abstain_reason set + empty changes → outcome === abstained', async () => {
    const { deps } = makeDeps(tmpDir);
    const input = makeInput();

    const result = makeResult({
      memory_changes: [],
      piece_changes: { should_edit: false },
      abstain_reason: 'nothing interesting happened',
    });

    const r = await applyReflection(deps, input, result);

    expect(r.memoryDecisions).toHaveLength(0);
    expect(r.outcome).toBe('abstained');
  });

  // ── concurrent appliers serialize via lock ───────────────────────────────────
  it('concurrent appliers serialize — second applier sees stale revision', async () => {
    const { deps } = makeDeps(tmpDir);
    const { revision, storedBody } = seedEntry(tmpDir, 'concurrent_entry', 'original body for concurrency test');

    // Both callers use the same observedRevisions snapshot.
    const sharedInput = makeInput({
      observedRevisions: { concurrent_entry: revision },
      memoryEntries: [
        { name: 'concurrent_entry', description: 'desc', type: 'user', body: storedBody },
      ],
    });

    const result1 = makeResult({
      memory_changes: [
        {
          op: 'update',
          name: 'concurrent_entry',
          type: 'user',
          description: 'updated by first',
          body: 'body from first applier',
          merge_target: 'concurrent_entry',
        },
      ],
    });

    const result2 = makeResult({
      memory_changes: [
        {
          op: 'update',
          name: 'concurrent_entry',
          type: 'user',
          description: 'updated by second',
          body: 'body from second applier',
          merge_target: 'concurrent_entry',
        },
      ],
    });

    // Fire both concurrently.  The lock serializes them.  The first to acquire
    // does the CAS check against `revision` (matches), writes, and releases.
    // The second does the CAS check against the now-changed body → stale.
    const [r1, r2] = await Promise.all([
      applyReflection(deps, sharedInput, result1),
      applyReflection(deps, sharedInput, result2),
    ]);

    const accepted = [r1, r2].filter((r) => r.memoryDecisions[0]?.accepted);
    const rejected = [r1, r2].filter((r) => !r.memoryDecisions[0]?.accepted);

    expect(accepted).toHaveLength(1);
    expect(rejected).toHaveLength(1);
    expect(rejected[0]?.memoryDecisions[0]?.code).toBe('rejected_stale_target');
  });

  // ── piece changes: rejected piece recorded ──────────────────────────────────
  it('rejected piece decision captured in pieceRejectCode', async () => {
    const { deps } = makeDeps(tmpDir);
    const input = makeInput();

    const result = makeResult({
      memory_changes: [],
      piece_changes: {
        should_edit: true,
        target_piece: 'other_piece', // mismatch with pieceName='chat' → rejected
        new_yaml: 'name: other_piece\nmovements:\n  - name: m1\n    rules: []\n',
      },
    });

    const r = await applyReflection(deps, input, result);

    expect(r.pieceApplied).toBe(false);
    expect(r.pieceRejectCode).toBe('rejected_target_piece_mismatch');
    expect(r.outcome).toBe('rejected');
  });

  // ── piece changes: accepted + builtin source → fork + write + DB row + catalog invalidate ─
  it('accepted piece change + builtin source → silent fork, file written, reflection_piece_edits row, catalog invalidated, pieceApplied=true', async () => {
    const { deps, repo, catalog, builtinDir } = makeDeps(tmpDir);

    // Seed the builtin piece so silentFork can copy it.
    const builtinYaml = 'movements:\n  - name: execute\n    rules: []\n';
    writeFileSync(join(builtinDir, 'chat.yaml'), builtinYaml);

    // Warm the catalog so we can verify invalidation.
    catalog.getForUser(USER_ID);

    const input = makeInput({ pieceSource: 'builtin' });
    const newYaml = 'movements:\n  - name: improved\n    rules: []\n';

    const result = makeResult({
      memory_changes: [],
      piece_changes: {
        should_edit: true,
        target_piece: 'chat',
        new_yaml: newYaml,
      },
    });

    const r = await applyReflection(deps, input, result);

    expect(r.pieceApplied).toBe(true);
    expect(r.pieceCooldownDropped).toBeFalsy();
    expect(r.pieceRejectCode).toBeUndefined();
    // Only piece applied, no memory → 'applied'.
    expect(r.outcome).toBe('applied');

    // Verify the custom piece file was created with the new YAML.
    const userPiecePath = join(tmpDir, USER_ID, 'pieces', 'chat.yaml');
    expect(existsSync(userPiecePath)).toBe(true);
    expect(readFileSync(userPiecePath, 'utf-8')).toBe(newYaml);

    // Verify the DB row was inserted.
    const count = repo.countRecentPieceEdits(USER_ID, 'chat', 24 * 3600 * 1000);
    expect(count).toBe(1);

    // Verify catalog was invalidated: after invalidation, the user's piece
    // should appear as 'custom'.
    const entries = catalog.getForUser(USER_ID);
    const entry = entries.find(e => e.name === 'chat');
    expect(entry?.source).toBe('custom');
  });

  // ── piece changes: accepted + 2 prior cooldown edits → cooldown drop ────────
  it('accepted piece change + 2 prior cooldown edits → pieceCooldownDropped=true, no fork, no DB row, memory changes still applied', async () => {
    const { deps, repo, builtinDir } = makeDeps(tmpDir);

    writeFileSync(join(builtinDir, 'chat.yaml'), 'movements:\n  - name: execute\n    rules: []\n');

    // Pre-insert 2 edits within the cooldown window.
    const now = Date.now();
    repo.getDb()
      .prepare(
        `INSERT INTO reflection_piece_edits (user_id, piece_name, snapshot_id, created_at)
         VALUES (?, ?, ?, ?)`
      )
      .run(USER_ID, 'chat', 'snap-pre-1', now - 2);
    repo.getDb()
      .prepare(
        `INSERT INTO reflection_piece_edits (user_id, piece_name, snapshot_id, created_at)
         VALUES (?, ?, ?, ?)`
      )
      .run(USER_ID, 'chat', 'snap-pre-2', now - 1);

    const input = makeInput({ pieceSource: 'builtin' });
    const result = makeResult({
      memory_changes: [
        // A valid memory add — should still be applied.
        { op: 'add', name: 'mem_fact', type: 'user', description: 'a fact', body: 'some body' },
      ],
      piece_changes: {
        should_edit: true,
        target_piece: 'chat',
        new_yaml: 'movements:\n  - name: blocked\n    rules: []\n',
      },
    });

    const r = await applyReflection(deps, input, result);

    expect(r.pieceApplied).toBe(false);
    expect(r.pieceCooldownDropped).toBe(true);
    expect(r.pieceRejectCode).toBeUndefined();

    // Memory change was still applied.
    expect(r.memoryDecisions[0]?.accepted).toBe(true);
    const entry = readMemoryEntry(tmpDir, USER_ID, 'mem_fact');
    expect(entry).not.toBeNull();

    // Memory applied + piece dropped → 'partial'.
    expect(r.outcome).toBe('partial');

    // No fork should have happened.
    const userPiecePath = join(tmpDir, USER_ID, 'pieces', 'chat.yaml');
    expect(existsSync(userPiecePath)).toBe(false);

    // Edit count must remain at 2 (the third was not recorded).
    const count = repo.countRecentPieceEdits(USER_ID, 'chat', 24 * 3600 * 1000);
    expect(count).toBe(2);
  });

  // ── piece changes: accepted + cooldown + no memory → outcome rejected ────────
  it('accepted piece change + cooldown + no memory applied → outcome === rejected', async () => {
    const { deps, repo, builtinDir } = makeDeps(tmpDir);

    writeFileSync(join(builtinDir, 'chat.yaml'), 'movements:\n  - name: execute\n    rules: []\n');

    // Pre-insert 2 edits to trigger cooldown.
    const now = Date.now();
    repo.getDb()
      .prepare(
        `INSERT INTO reflection_piece_edits (user_id, piece_name, snapshot_id, created_at)
         VALUES (?, ?, ?, ?)`
      )
      .run(USER_ID, 'chat', 'snap-pre-a', now - 2);
    repo.getDb()
      .prepare(
        `INSERT INTO reflection_piece_edits (user_id, piece_name, snapshot_id, created_at)
         VALUES (?, ?, ?, ?)`
      )
      .run(USER_ID, 'chat', 'snap-pre-b', now - 1);

    const input = makeInput({ pieceSource: 'builtin' });
    const result = makeResult({
      memory_changes: [],
      piece_changes: {
        should_edit: true,
        target_piece: 'chat',
        new_yaml: 'movements:\n  - name: blocked\n    rules: []\n',
      },
    });

    const r = await applyReflection(deps, input, result);

    expect(r.pieceApplied).toBe(false);
    expect(r.pieceCooldownDropped).toBe(true);
    // No memory applied + piece dropped → 'rejected'.
    expect(r.outcome).toBe('rejected');
  });

  // ── piece changes: accepted + custom source → no fork, file overwritten, row inserted ─
  it('accepted piece change + custom source already exists → no fork, file overwritten, row inserted', async () => {
    const { deps, repo } = makeDeps(tmpDir);

    // Pre-create a custom piece (simulating a previously forked piece).
    const userPieceDir = join(tmpDir, USER_ID, 'pieces');
    mkdirSync(userPieceDir, { recursive: true });
    const userPiecePath = join(userPieceDir, 'chat.yaml');
    writeFileSync(userPiecePath, 'movements:\n  - name: old\n    rules: []\n');

    const newYaml = 'movements:\n  - name: updated\n    rules: []\n';
    const input = makeInput({ pieceSource: 'custom' });
    const result = makeResult({
      memory_changes: [],
      piece_changes: {
        should_edit: true,
        target_piece: 'chat',
        new_yaml: newYaml,
      },
    });

    const r = await applyReflection(deps, input, result);

    expect(r.pieceApplied).toBe(true);
    expect(r.pieceCooldownDropped).toBeFalsy();
    expect(r.pieceRejectCode).toBeUndefined();
    expect(r.outcome).toBe('applied');

    // File should be overwritten with the new YAML.
    expect(readFileSync(userPiecePath, 'utf-8')).toBe(newYaml);

    // DB row should be inserted.
    const count = repo.countRecentPieceEdits(USER_ID, 'chat', 24 * 3600 * 1000);
    expect(count).toBe(1);
  });
});
