// src/engine/reflection/applier.ts
//
// Applies a validated ReflectionResult to the user's memory directory under a
// per-user lock with CAS (Compare-And-Swap) revision checks for non-add ops.
//
// Invariants:
//   - Runs inside withUserLock — serializes all writes for a given user.
//   - Validates via validateReflectionResult first; rejections are recorded
//     with their semantic code and produce no disk side-effects.
//   - Non-'add' ops require merge_target + a matching body revision.  The
//     revision is SHA-1 of the parsed body, computed by bodyRevision() —
//     the same helper used by loadReflectionInputs to build observedRevisions.
//   - Hard cap of 3 memory changes; extra entries are dropped with WARN.
//   - Op semantics:
//       add        → upsertMemoryEntry (rejected if name already exists on disk,
//                    caught by semantic validator; applier trusts that check)
//       update     → upsertMemoryEntry (replace body/description/type + CAS)
//       merge_into → read existing, append timestamped section, upsert merged
//       remove     → removeMemoryEntry (move to trash + update index)
//   - Piece writes: writePiece() is called for accepted piece decisions.
//     Cooldown drops set pieceCooldownDropped=true (separate from the 10
//     semantic ReflectionRejectionCode values — this is an operational gate,
//     not a semantic one).
//   - snapshotId: computed by crypto.randomUUID() at the top of applyReflection.
//     Phase 7 may supply it externally via deps.snapshotId if it needs to tie
//     this to a snapshot written upstream; for now a fresh UUID per call keeps
//     test fixtures simple (no need to pre-mint an ID).
//   - outcome ∈ {applied, partial, abstained, rejected, failed}; only the
//     outer runner produces 'failed'.

import { randomUUID } from 'crypto';
import { logger } from '../../logger.js';
import {
  upsertMemoryEntry,
  removeMemoryEntry,
  readMemoryEntry,
} from '../../user-folder/memory.js';
import { withUserLock } from './user-lock.js';
import { bodyRevision } from './revisions.js';
import { writePiece } from './piece-writer.js';
import type {
  MemoryChange,
  ReflectionInput,
  ReflectionResult,
  ReflectionOutcome,
} from './types.js';
import {
  validateReflectionResult,
  type ValidatorOutput,
} from './semantic-validator.js';
import type { MemoryType } from '../../user-folder/memory.js';
import type { Repository } from '../../db/repository.js';
import type { PieceCatalog } from '../piece-catalog.js';

// ── Public types ──────────────────────────────────────────────────────────────

export interface ApplierDeps {
  dataDir: string;
  maxBodyBytes: number;
  /** Dependencies required to write accepted piece changes (Phase 6.4). */
  repo: Repository;
  catalog: PieceCatalog;
  /** Path to the built-in pieces directory, e.g. "pieces". */
  builtinDir: string;
  /** Cooldown window for piece writes in hours. Default: 24. */
  cooldownHours?: number;
  /**
   * Snapshot ID to tie piece edits to a specific reflection run.
   * If omitted, a fresh UUID is generated per applyReflection call.
   * Phase 7 may supply it externally to link this to a snapshot written upstream.
   */
  snapshotId?: string;
}

export interface MemoryApplyDecision {
  change: MemoryChange;
  accepted: boolean;
  code?: string;
  reason?: string;
}

export interface ApplierResult {
  memoryDecisions: MemoryApplyDecision[];
  pieceApplied: boolean;
  /**
   * Set when the semantic validator rejected the piece change.
   * Contains one of the 10 ReflectionRejectionCode values.
   * Distinct from pieceCooldownDropped — that is an operational rate-limit,
   * not a semantic rejection.
   */
  pieceRejectCode?: string;
  /**
   * True when writePiece() returned { written: false, reason: 'cooldown' }.
   * Kept separate from pieceRejectCode because the cooldown is an operational
   * gate (too many edits in the window) rather than a semantic code from the
   * validator's 10-code set.
   */
  pieceCooldownDropped?: boolean;
  outcome: ReflectionOutcome;
}

// ── Main export ───────────────────────────────────────────────────────────────

/**
 * Apply a ReflectionResult to disk under the per-user lock.
 *
 * Sequence (all inside withUserLock):
 *   1. validateReflectionResult — semantic checks, no I/O
 *   2. For each change (capped at 3):
 *      a. If validator rejected → record decision, skip
 *      b. CAS check for non-add ops (read current body, hash, compare)
 *      c. applyOne — the actual write
 *   3. Piece decision:
 *      a. Semantically rejected → pieceRejectCode set
 *      b. Semantically accepted + writePiece returns written:true → pieceApplied=true
 *      c. Semantically accepted + writePiece returns cooldown → pieceCooldownDropped=true
 *   4. Compute and return outcome
 */
export async function applyReflection(
  deps: ApplierDeps,
  input: ReflectionInput,
  result: ReflectionResult,
): Promise<ApplierResult> {
  return withUserLock(deps.dataDir, input.userId, () =>
    applyReflectionUnlocked(deps, input, result),
  );
}

/**
 * Inner applier logic — MUST be called while already holding the per-user lock
 * (see withUserLock).  Exists so the reflection runner can hold the lock for
 * the full apply+snapshot critical section without nested-lock errors.
 *
 * External callers should use `applyReflection` (which acquires the lock).
 */
export async function applyReflectionUnlocked(
  deps: ApplierDeps,
  input: ReflectionInput,
  result: ReflectionResult,
): Promise<ApplierResult> {
  // Mint a snapshot ID for piece-edit tracking.  Phase 7 may supply one via
  // deps.snapshotId to tie this to a snapshot written earlier in the pipeline.
  const snapshotId = deps.snapshotId ?? randomUUID();

  const validation: ValidatorOutput = validateReflectionResult(result, input, {
      maxBodyBytes: deps.maxBodyBytes,
    });
    const decisions: MemoryApplyDecision[] = [];

    // Hard cap: only the first 3 changes are processed.
    const cap = 3;
    if (result.memory_changes.length > cap) {
      logger.warn(
        `[reflection/applier] memory_changes truncated to ${cap} ` +
        `changes=${result.memory_changes.length} userId=${input.userId} ` +
        `jobId=${input.originalJobId}`,
      );
    }

    for (let i = 0; i < Math.min(cap, result.memory_changes.length); i++) {
      const change = result.memory_changes[i]!;
      const decision = validation.memoryDecisions[i]!;

      if (!decision.accepted) {
        decisions.push({
          change,
          accepted: false,
          code: decision.code,
          reason: decision.reason,
        });
        continue;
      }

      // CAS check for non-add ops: compare observedRevisions against current disk state.
      if (change.op !== 'add') {
        const target = change.merge_target!;

        // Read the current entry from disk.
        const current = readMemoryEntry(deps.dataDir, input.userId, target);
        if (!current) {
          decisions.push({
            change,
            accepted: false,
            code: 'rejected_missing_target',
            reason: `merge_target="${target}" not found on disk at apply time`,
          });
          continue;
        }

        const expected = input.observedRevisions[target];
        const actual = bodyRevision(current.body);
        if (expected !== actual) {
          decisions.push({
            change,
            accepted: false,
            code: 'rejected_stale_target',
            reason: `merge_target="${target}" body changed since snapshot (expected=${expected?.slice(0, 8)} actual=${actual.slice(0, 8)})`,
          });
          continue;
        }
      }

      // Accepted + CAS passed → apply to disk.
      try {
        applyOne(deps.dataDir, input.userId, change);
        decisions.push({ change, accepted: true });
      } catch (e) {
        decisions.push({
          change,
          accepted: false,
          code: 'failed',
          reason: String(e),
        });
      }
    }

    // Piece changes: call writePiece() for accepted decisions.
    let pieceApplied = false;
    let pieceRejectCode: string | undefined;
    let pieceCooldownDropped = false;

    if (validation.pieceDecision) {
      if (!validation.pieceDecision.accepted) {
        // Semantic rejection — one of the 10 ReflectionRejectionCode values.
        pieceRejectCode = validation.pieceDecision.code;
      } else {
        // Semantically accepted — attempt the write.
        try {
          const writeResult = await writePiece(
            {
              userId: input.userId,
              pieceName: input.pieceName,
              pieceSource: input.pieceSource,
              newYaml: result.piece_changes.new_yaml!,
              snapshotId,
              cooldownHours: deps.cooldownHours,
            },
            {
              dataDir: deps.dataDir,
              builtinDir: deps.builtinDir,
            },
            deps.repo,
            deps.catalog,
          );

          if (writeResult.written) {
            pieceApplied = true;
          } else {
            // Operational cooldown gate — distinct from semantic rejection codes.
            pieceCooldownDropped = true;
            logger.info(
              `[reflection/applier] piece write dropped by cooldown ` +
              `userId=${input.userId} piece=${input.pieceName} snapshotId=${snapshotId}`,
            );
          }
        } catch (e) {
          // Unexpected write error: log and treat as not applied (memory changes
          // already written are kept — they were under the same lock).
          logger.error(
            `[reflection/applier] writePiece threw userId=${input.userId} ` +
            `piece=${input.pieceName}: ${e}`,
          );
        }
      }
    }

  const outcome = decideOutcome(decisions, pieceApplied, pieceRejectCode, pieceCooldownDropped, result);
  return { memoryDecisions: decisions, pieceApplied, pieceRejectCode, pieceCooldownDropped, outcome };
}

// ── Internal helpers ──────────────────────────────────────────────────────────

/**
 * Execute a single validated, CAS-checked memory change on disk.
 */
function applyOne(dataDir: string, userId: string, c: MemoryChange): void {
  switch (c.op) {
    case 'add':
    case 'update':
      upsertMemoryEntry(dataDir, userId, {
        name: c.name,
        description: c.description,
        type: c.type as MemoryType,
        body: c.body,
      });
      return;

    case 'merge_into': {
      const target = c.merge_target!;
      const existing = readMemoryEntry(dataDir, userId, target);
      // existing is guaranteed non-null because CAS passed above.
      const today = new Date().toISOString().slice(0, 10);
      const merged = `${existing!.body}\n\n---\n## Updated ${today}\n${c.body}`;
      upsertMemoryEntry(dataDir, userId, {
        name: target,
        description: existing!.meta.description,
        type: existing!.meta.type as MemoryType,
        body: merged,
      });
      return;
    }

    case 'remove':
      removeMemoryEntry(dataDir, userId, c.merge_target!);
      return;
  }
}

/**
 * Compute the final outcome from the set of apply decisions.
 *
 * Rules (checked in order):
 *   1. abstain_reason present AND no decisions AND no piece action → 'abstained'
 *   2. any applied AND any rejected-or-dropped → 'partial'
 *   3. any applied (and nothing rejected or dropped) → 'applied'
 *   4. any rejected or dropped (and nothing applied) → 'rejected'
 *   5. fallback → 'abstained' (empty input, e.g. zero memory_changes + no piece edit)
 *
 * pieceCooldownDropped is treated the same as a rejection for outcome
 * purposes (something was intended but not written), but kept in its own
 * field so callers can distinguish the operational drop from semantic codes.
 */
function decideOutcome(
  decisions: MemoryApplyDecision[],
  pieceApplied: boolean,
  pieceRejectCode: string | undefined,
  pieceCooldownDropped: boolean,
  result: ReflectionResult,
): ReflectionOutcome {
  const anyApplied = decisions.some((d) => d.accepted) || pieceApplied;
  const anyRejected = decisions.some((d) => !d.accepted) || !!pieceRejectCode || pieceCooldownDropped;

  if (result.abstain_reason && !anyApplied && !anyRejected) return 'abstained';
  if (anyApplied && anyRejected) return 'partial';
  if (anyApplied) return 'applied';
  if (anyRejected) return 'rejected';
  return 'abstained';
}
