// src/engine/reflection/reflection-runner.snapshot.test.ts
//
// Runner-side snapshot-capture edges left PARTIAL by reflection-runner.test.ts:
//
//   REFL-044  before/after file capture for a REMOVE op uses merge_target (not
//             change.name) for the before-file key, and writes NO after-file.
//   REFL-045  piece YAML before/after capture: before = input.pieceYaml; after is
//             read from the custom piece path ONLY when it exists on disk
//             (existsSync guard) — absent path → no after YAML, no crash.
//   REFL-013  writeSnapshot throwing is NON-FATAL even when there is real
//             before/after capture work: the runner still returns the applier's
//             outcome and records the metric (degraded, not crashed).
//
// Mock conventions copied verbatim from reflection-runner.test.ts: every collaborator
// is vi.mock'd, withUserLock just runs its callback, and we inspect the args the
// runner passed to the (mocked) writeSnapshot.  The exception is the real FS: the
// runner calls existsSync/readFileSync on the custom piece path, so tests that
// assert piece-after capture seed that real file under a temp data dir.

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import type { Job, Repository } from '../../db/repository.js';
import type { AppConfig } from '../../config.js';

vi.mock('./load-inputs.js', () => ({ loadReflectionInputs: vi.fn() }));
vi.mock('./reflection-prompt.js', () => ({
  buildSystemPrompt: vi.fn().mockReturnValue('SYSTEM'),
  buildUserPrompt: vi.fn().mockReturnValue('USER'),
}));
vi.mock('./llm-client.js', () => ({ callReflectionLlm: vi.fn() }));
vi.mock('./applier.js', () => ({ applyReflectionUnlocked: vi.fn() }));
vi.mock('./snapshot.js', () => ({ writeSnapshot: vi.fn() }));
vi.mock('./user-lock.js', () => ({
  withUserLock: vi.fn(async (_dir: string, _user: string, fn: () => Promise<void>) => fn()),
}));
vi.mock('../piece-catalog.js', () => ({ PieceCatalog: vi.fn() }));

import { loadReflectionInputs } from './load-inputs.js';
import { callReflectionLlm } from './llm-client.js';
import { applyReflectionUnlocked } from './applier.js';
import { writeSnapshot } from './snapshot.js';
import { runReflectionJob } from './reflection-runner.js';

const LLM_RESULT = {
  parsed: { reasoning: 'because' },
  tokensIn: 100,
  tokensOut: 50,
  durationMs: 5,
};

function makeJob(payload: unknown): Job {
  return { id: 'refl-snap-1', payload: JSON.stringify(payload) } as unknown as Job;
}

function makeDeps(dataDir: string) {
  const repo = { recordReflectionMetric: vi.fn() } as unknown as Repository;
  return {
    repo,
    config: { reflection: {}, userFolderRoot: dataDir } as unknown as AppConfig,
    llmEndpoint: 'http://localhost:1',
    llmApiKey: 'sk-test',
    llmModel: 'test-model',
  };
}

describe('runReflectionJob snapshot capture', () => {
  let dataDir: string;

  beforeEach(() => {
    vi.clearAllMocks();
    dataDir = mkdtempSync(join(tmpdir(), 'refl-snap-'));
    vi.mocked(callReflectionLlm).mockResolvedValue(LLM_RESULT as never);
    vi.mocked(writeSnapshot).mockResolvedValue({ dir: '/snap/dir' } as never);
  });

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

  // ── REFL-044: remove-op before-file capture keyed on merge_target ────────────
  it('REFL-044: an accepted REMOVE decision captures the before-file under merge_target and writes no after-file', async () => {
    const deps = makeDeps(dataDir);

    // input.memoryEntries must contain the entry under the merge_target name so the
    // runner can build the before-file from the prior body.
    vi.mocked(loadReflectionInputs).mockResolvedValue({
      memoryEntries: [
        { name: 'old_fact', description: 'prior desc', type: 'user', body: 'prior body' },
      ],
      pieceYaml: 'name: chat',
    } as never);

    // Accepted remove whose change.name differs from merge_target — proves the
    // runner keys the before-file on merge_target, not change.name.
    vi.mocked(applyReflectionUnlocked).mockResolvedValue({
      outcome: 'applied',
      memoryDecisions: [
        {
          accepted: true,
          change: {
            op: 'remove',
            name: 'some_other_name',
            merge_target: 'old_fact',
            description: 'prior desc',
            type: 'user',
            body: '',
          },
        },
      ],
      pieceApplied: false,
    } as never);

    await runReflectionJob(deps, makeJob({
      originalJobId: 'orig-rm', userId: 'user-1', pieceName: 'chat', outcome: 'succeeded',
    }));

    expect(writeSnapshot).toHaveBeenCalledTimes(1);
    const call = vi.mocked(writeSnapshot).mock.calls[0]!;
    const beforeFiles = call[1] as Record<string, string>;
    const afterFiles = call[2] as Record<string, string>;

    // before-file keyed on merge_target ('old_fact'), NOT change.name.
    expect(Object.keys(beforeFiles)).toEqual(['old_fact.md']);
    expect(beforeFiles['old_fact.md']).toContain('prior body');
    expect(beforeFiles).not.toHaveProperty('some_other_name.md');

    // remove → no after-file for that entry.
    expect(afterFiles).toEqual({});
  });

  // ── REFL-045: piece before/after capture reads the custom path when present ──
  it('REFL-045: piece-applied capture sets before=input.pieceYaml and after=on-disk custom YAML', async () => {
    const deps = makeDeps(dataDir);

    // Seed the custom piece file the runner reads via userPiecesDir(dataDir, leaf).
    // Layout: <dataDir>/<userId>/pieces/<piece>.yaml
    const piecesDir = join(dataDir, 'user-1', 'pieces');
    mkdirSync(piecesDir, { recursive: true });
    writeFileSync(join(piecesDir, 'chat.yaml'), 'movements:\n  - name: improved\n    rules: []\n');

    vi.mocked(loadReflectionInputs).mockResolvedValue({
      memoryEntries: [],
      pieceYaml: 'movements:\n  - name: original\n    rules: []\n',
    } as never);

    vi.mocked(applyReflectionUnlocked).mockResolvedValue({
      outcome: 'applied',
      memoryDecisions: [],
      pieceApplied: true,
    } as never);

    await runReflectionJob(deps, makeJob({
      originalJobId: 'orig-pc', userId: 'user-1', pieceName: 'chat', outcome: 'succeeded',
    }));

    const call = vi.mocked(writeSnapshot).mock.calls[0]!;
    const pieceBefore = call[4] as string | undefined;
    const pieceAfter = call[5] as string | undefined;

    expect(pieceBefore).toBe('movements:\n  - name: original\n    rules: []\n');
    expect(pieceAfter).toBe('movements:\n  - name: improved\n    rules: []\n');
  });

  it('REFL-045: piece applied but custom path absent on disk → before set, after undefined, no crash', async () => {
    const deps = makeDeps(dataDir);
    // Deliberately do NOT create <dataDir>/user-1/pieces/chat.yaml.

    vi.mocked(loadReflectionInputs).mockResolvedValue({
      memoryEntries: [],
      pieceYaml: 'movements:\n  - name: original\n    rules: []\n',
    } as never);

    vi.mocked(applyReflectionUnlocked).mockResolvedValue({
      outcome: 'applied',
      memoryDecisions: [],
      pieceApplied: true,
    } as never);

    const outcome = await runReflectionJob(deps, makeJob({
      originalJobId: 'orig-pc2', userId: 'user-1', pieceName: 'chat', outcome: 'succeeded',
    }));

    expect(outcome).toBe('applied');
    const call = vi.mocked(writeSnapshot).mock.calls[0]!;
    const pieceBefore = call[4] as string | undefined;
    const pieceAfter = call[5] as string | undefined;
    expect(pieceBefore).toBe('movements:\n  - name: original\n    rules: []\n');
    // existsSync(customPath) === false → after stays undefined (guard), no throw.
    expect(pieceAfter).toBeUndefined();
  });

  it('REFL-045: piece NOT applied → neither before nor after YAML captured', async () => {
    const deps = makeDeps(dataDir);
    vi.mocked(loadReflectionInputs).mockResolvedValue({
      memoryEntries: [],
      pieceYaml: 'movements:\n  - name: original\n    rules: []\n',
    } as never);
    vi.mocked(applyReflectionUnlocked).mockResolvedValue({
      outcome: 'abstained',
      memoryDecisions: [],
      pieceApplied: false,
    } as never);

    await runReflectionJob(deps, makeJob({
      originalJobId: 'orig-pc3', userId: 'user-1', pieceName: 'chat', outcome: 'succeeded',
    }));

    const call = vi.mocked(writeSnapshot).mock.calls[0]!;
    expect(call[4]).toBeUndefined();
    expect(call[5]).toBeUndefined();
  });

  // ── REFL-013: writeSnapshot throwing is non-fatal even with real capture work ─
  it('REFL-013: writeSnapshot throwing is non-fatal — outcome still applied, metric recorded with real capture present', async () => {
    const deps = makeDeps(dataDir);
    vi.mocked(loadReflectionInputs).mockResolvedValue({
      memoryEntries: [
        { name: 'kept', description: 'd', type: 'user', body: 'before body' },
      ],
      pieceYaml: 'name: chat',
    } as never);
    vi.mocked(applyReflectionUnlocked).mockResolvedValue({
      outcome: 'applied',
      memoryDecisions: [
        { accepted: true, change: { op: 'update', name: 'kept', merge_target: 'kept', description: 'd', type: 'user', body: 'after body' } },
      ],
      pieceApplied: false,
    } as never);
    // Snapshot write blows up mid-capture.
    vi.mocked(writeSnapshot).mockRejectedValue(new Error('disk full'));

    const outcome = await runReflectionJob(deps, makeJob({
      originalJobId: 'orig-throw', userId: 'user-1', pieceName: 'chat', outcome: 'succeeded',
    }));

    // Non-fatal: applier outcome survives, metric still recorded (not 'failed').
    expect(outcome).toBe('applied');
    expect(deps.repo.recordReflectionMetric).toHaveBeenCalledWith(
      expect.objectContaining({ outcome: 'applied', memory_changes: 1, tokens_in: 100, tokens_out: 50 }),
    );
  });
});
