// src/engine/reflection/applier.edge.test.ts
//
// Edge-case coverage for the applier that the happy-path suite (applier.test.ts)
// and the fuzz suite (applier.fuzz.test.ts) leave PARTIAL:
//
//   REFL-032  applyOne write-failure mid-loop → that single change is recorded
//             as code='failed' (distinct from runner-level outcome='failed'),
//             other changes still apply, outcome reflects the mix.
//   REFL-031  >3 memory_changes → only the first 3 are processed AND a WARN is
//             logged that names the truncated count (boundary assertion).
//   REFL-037  decideOutcome empty / no-op input (0 changes, no piece, no
//             abstain_reason) → falls back to 'abstained'.
//
// Style mirrors applier.test.ts: real Repository (temp-file SQLite), real
// PieceCatalog, real user-lock, temp data dir.  The ONLY thing mocked is the
// memory module's write side-effect (upsertMemoryEntry), so we can inject a
// per-change disk failure while leaving validation, CAS, and read/remove real.

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

// ── Partial mock of the memory module ──────────────────────────────────────────
// Keep everything real (isValidMemoryName for the validator, readMemoryEntry,
// removeMemoryEntry, listMemoryEntries) EXCEPT upsertMemoryEntry, which we wrap so
// individual tests can make a specific write throw. The wrapper delegates to the
// GENUINE upsert (captured inside the factory — NOT re-imported from the mocked
// module, which would just be the wrapper again → infinite recursion). Tests add
// a name to `failNames` to make that single change's disk write throw.
const failNames = new Set<string>();
vi.mock('../../user-folder/memory.js', async (importActual) => {
  const actual = await importActual<typeof import('../../user-folder/memory.js')>();
  const realUpsert = actual.upsertMemoryEntry;
  return {
    ...actual,
    upsertMemoryEntry: (...args: Parameters<typeof actual.upsertMemoryEntry>) => {
      const entry = args[2] as { name: string };
      if (failNames.has(entry.name)) {
        throw new Error(`disk write blew up: ${entry.name}`);
      }
      return realUpsert(...args);
    },
  };
});

// logger is imported by the applier; spy on .warn to assert the truncation WARN.
import { logger } from '../../logger.js';

import { applyReflection, type ApplierDeps } from './applier.js';
import { readMemoryEntry } from '../../user-folder/memory.js';
import { Repository } from '../../db/repository.js';
import { PieceCatalog } from '../piece-catalog.js';
import type { ReflectionInput, ReflectionResult } from './types.js';

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

function makeDeps(tmpDir: string): { deps: ApplierDeps; repo: Repository } {
  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 };
}

function makeInput(overrides: Partial<ReflectionInput> = {}): ReflectionInput {
  return {
    originalJobId: 'j-edge',
    userId: USER_ID,
    pieceName: 'chat',
    pieceSource: 'builtin',
    outcome: 'succeeded',
    taskTitle: 'edge task',
    taskBody: 'do the edge 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,
  };
}

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

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

  afterEach(() => {
    rmSync(tmpDir, { recursive: true, force: true });
    vi.restoreAllMocks();
    failNames.clear();
  });

  // ── REFL-032: applyOne write-failure mid-loop ────────────────────────────────
  it('REFL-032: a per-change write that throws is recorded code=failed; other changes still apply → partial', async () => {
    const { deps } = makeDeps(tmpDir);

    // Make the SECOND add throw at write time, the first succeed (real upsert).
    failNames.add('will_fail');

    const input = makeInput();
    const result = makeResult({
      memory_changes: [
        { op: 'add', name: 'will_apply', type: 'user', description: 'ok', body: 'ok body' },
        { op: 'add', name: 'will_fail', type: 'user', description: 'boom', body: 'boom body' },
      ],
    });

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

    // First change accepted, written to disk.
    expect(r.memoryDecisions[0]?.accepted).toBe(true);
    expect(readMemoryEntry(tmpDir, USER_ID, 'will_apply')).not.toBeNull();

    // Second change: validation passed, CAS n/a (add), but applyOne threw →
    // recorded as a failed decision (code='failed'), NOT a semantic reject code.
    expect(r.memoryDecisions[1]?.accepted).toBe(false);
    expect(r.memoryDecisions[1]?.code).toBe('failed');
    expect(r.memoryDecisions[1]?.reason).toContain('disk write blew up: will_fail');
    // The failed write left no file behind.
    expect(readMemoryEntry(tmpDir, USER_ID, 'will_fail')).toBeNull();

    // One applied + one failed-as-rejected → partial.
    expect(r.outcome).toBe('partial');
  });

  it('REFL-032: when the ONLY change throws at write time → outcome=rejected (nothing applied)', async () => {
    const { deps } = makeDeps(tmpDir);
    failNames.add('lonely');

    const input = makeInput();
    const result = makeResult({
      memory_changes: [
        { op: 'add', name: 'lonely', type: 'user', description: 'd', body: 'b' },
      ],
    });

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

    expect(r.memoryDecisions).toHaveLength(1);
    expect(r.memoryDecisions[0]?.accepted).toBe(false);
    expect(r.memoryDecisions[0]?.code).toBe('failed');
    // No applied, one "rejected" (failed) → rejected outcome (not the runner-level 'failed').
    expect(r.outcome).toBe('rejected');
  });

  // ── REFL-031: >3 memory_changes truncated with a WARN ────────────────────────
  it('REFL-031: 5 valid changes → only first 3 processed AND a WARN names the original count', async () => {
    const { deps } = makeDeps(tmpDir);
    const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});

    const input = makeInput();
    const result = makeResult({
      memory_changes: [
        { op: 'add', name: 'a1', type: 'user', description: 'd', body: 'b1' },
        { op: 'add', name: 'a2', type: 'user', description: 'd', body: 'b2' },
        { op: 'add', name: 'a3', type: 'user', description: 'd', body: 'b3' },
        { op: 'add', name: 'a4', type: 'user', description: 'd', body: 'b4' },
        { op: 'add', name: 'a5', type: 'user', description: 'd', body: 'b5' },
      ],
    });

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

    // Exactly 3 decisions, all accepted.
    expect(r.memoryDecisions).toHaveLength(3);
    expect(r.memoryDecisions.every((d) => d.accepted)).toBe(true);
    expect(r.outcome).toBe('applied');

    // The 4th and 5th were never written.
    expect(readMemoryEntry(tmpDir, USER_ID, 'a4')).toBeNull();
    expect(readMemoryEntry(tmpDir, USER_ID, 'a5')).toBeNull();

    // A truncation WARN was logged, naming the original (over-cap) count.
    const truncationWarn = warnSpy.mock.calls
      .map((c) => String(c[0]))
      .find((m) => m.includes('truncated'));
    expect(truncationWarn).toBeDefined();
    expect(truncationWarn).toContain('changes=5');
  });

  it('REFL-031 boundary: exactly 3 changes → NO truncation WARN', async () => {
    const { deps } = makeDeps(tmpDir);
    const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});

    const input = makeInput();
    const result = makeResult({
      memory_changes: [
        { op: 'add', name: 'b1', type: 'user', description: 'd', body: 'x' },
        { op: 'add', name: 'b2', type: 'user', description: 'd', body: 'x' },
        { op: 'add', name: 'b3', type: 'user', description: 'd', body: 'x' },
      ],
    });

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

    expect(r.memoryDecisions).toHaveLength(3);
    const truncationWarn = warnSpy.mock.calls
      .map((c) => String(c[0]))
      .find((m) => m.includes('truncated'));
    expect(truncationWarn).toBeUndefined();
  });

  // ── REFL-037: empty / no-op input → abstained fallback ───────────────────────
  it('REFL-037: empty input (0 changes, no piece, no abstain_reason) → outcome=abstained', async () => {
    const { deps } = makeDeps(tmpDir);
    const input = makeInput();
    // No memory_changes, no piece edit, AND no abstain_reason — the pure fallback
    // branch (distinct from the explicit abstain_reason path already covered).
    const result = makeResult({
      memory_changes: [],
      piece_changes: { should_edit: false },
    });
    expect(result.abstain_reason).toBeUndefined();

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

    expect(r.memoryDecisions).toHaveLength(0);
    expect(r.pieceApplied).toBe(false);
    expect(r.pieceRejectCode).toBeUndefined();
    expect(r.pieceCooldownDropped).toBeFalsy();
    expect(r.outcome).toBe('abstained');
  });
});
