/**
 * ExtractionResolver — given a raw LLM extraction output {mentions, facts},
 * translates it into resolved entities + persisted facts.
 *
 * Four-pass flow:
 *   1. For each mention: call memory.upsertEntityBySurface → map local label → entity id.
 *   2. Translate label references inside mention.metadata (e.g.
 *      `event.attendeeIds = ['m_self','m1']`, `task.assigneeId = 'm_john'`)
 *      to real entity ids and patch the entity. Unresolved labels are dropped
 *      and logged. Without this, downstream readers (resolveRelatedEvents,
 *      task-by-assignee queries) see prompt placeholders instead of ids.
 *   3. For each fact: translate subject/object/contextIds, attach sourceSignalId,
 *      call memory.addFact.
 *   4. Return result with resolved entities, written facts, merge candidates,
 *      and unresolved references (e.g., facts pointing to undefined mention labels).
 *
 * One bad mention or fact doesn't abort the whole ingest — errors are collected
 * per-item and surfaced in the result for caller review.
 */

import { logger } from '../../infrastructure/observability/Logger.js';
import type { MemorySystem } from '../MemorySystem.js';
import type {
  EntityCandidate,
  EntityId,
  FactId,
  FactKind,
  IEntity,
  IFact,
  Identifier,
  ScopeFilter,
} from '../types.js';

// =============================================================================
// Input / output shapes — these mirror the default extraction prompt's JSON format.
// =============================================================================

export interface ExtractionMention {
  surface: string;
  type: string;
  identifiers?: Identifier[];
  aliases?: string[];
  /**
   * Type-specific fields the LLM extracted alongside the mention (e.g.
   * `{ state: 'proposed', dueAt: '2026-04-30', assigneeId: 'm1' }` for a task).
   * Flows through `upsertEntityBySurface.metadata` — on create, set verbatim;
   * on resolve, conservative `fillMissing` merge (never overwrites existing).
   */
  metadata?: Record<string, unknown>;
}

export interface ExtractionFactSpec {
  subject: string;                  // local mention label
  predicate: string;
  object?: string;                  // local mention label
  value?: unknown;
  details?: string;
  summaryForEmbedding?: string;
  confidence?: number;
  importance?: number;
  contextIds?: string[];            // local mention labels
  kind?: FactKind;                  // default 'atomic'
  validFrom?: string | Date;
  validUntil?: string | Date;
  observedAt?: string | Date;
  /**
   * Verbatim quote from the source signal supporting this fact. Required
   * under `EagernessProfile.requireEvidenceQuote = 'strict'` (filtered by
   * `RestrainedExtractionContract`); pass-through otherwise. Stored on the
   * written fact as `IFact.evidenceQuote`.
   */
  evidenceQuote?: string;
}

/**
 * Reconciliation operations emitted by the LLM in per-conversation
 * reconciliation mode (when `priorFacts` is passed to the prompt). Each op is
 * dispatched against the existing fact graph: `create` adds, `update` mutates
 * in place, `archive` flips `archived: true`. The factId on `update`/`archive`
 * MUST be one of the priorFact ids; hallucinated ids are rejected.
 */
export type ReconciliationOp =
  | {
      op: 'create';
      subject: string;
      predicate: string;
      kind: FactKind;
      object?: string;
      objectId?: string;
      value?: unknown;
      details?: string;
      contextIds?: string[];
      evidenceQuote?: string;
      importance?: number;
      confidence?: number;
    }
  | {
      op: 'update';
      factId: FactId;
      newValue?: unknown;
      details?: string;
      evidenceQuote?: string;
      reason?: string;
    }
  | {
      op: 'archive';
      factId: FactId;
      evidenceQuote?: string;
      reason?: string;
    };

/** Counts of operations applied/rejected by the resolver. */
export interface OperationOutcome {
  creates: number;
  updates: number;
  archives: number;
  rejectedHallucinated: number;
  rejectedSkeptic: number;
}

export interface ExtractionOutput {
  mentions: Record<string, ExtractionMention>;
  facts: ExtractionFactSpec[];
  /** Reconciliation ops — populated when LLM ran in reconciliation mode. */
  operations?: ReconciliationOp[];
}

export interface IngestionResolvedEntity {
  label: string;
  entity: IEntity;
  resolved: boolean;
  mergeCandidates: EntityCandidate[];
}

export interface IngestionError {
  /** Which mention label / fact index failed. */
  where: string;
  reason: string;
}

export interface IngestionResult {
  entities: IngestionResolvedEntity[];
  facts: IFact[];
  /** Entities that matched existing records with mid-confidence candidates. */
  mergeCandidates: Array<{ label: string; surface: string; candidates: EntityCandidate[] }>;
  /** Mention labels or facts that couldn't be resolved/written. */
  unresolved: IngestionError[];
  /**
   * Canonicalized predicates in the LLM output that are NOT in the memory
   * system's predicate registry. Useful for detecting vocabulary drift —
   * periodically review and either promote to the registry or refine the
   * prompt. Empty when no registry is configured.
   * Deduped.
   */
  newPredicates: string[];
  /**
   * Op-level counts from reconciliation dispatch. Populated when the caller
   * passed `priorFacts` AND the LLM emitted `operations`. Undefined otherwise.
   */
  operationsApplied?: OperationOutcome;
}

export interface ExtractionResolverOptions {
  /** Override per-upsert threshold (default: memory system's config). */
  autoResolveThreshold?: number;
  /**
   * Pre-bound `label → entityId` map. When the LLM output references any of
   * these labels (as subject/object/contextId/mention), the resolver skips
   * upsert and uses the provided entity id directly. Intended for signal-level
   * metadata (email headers, calendar attendees) where identities are already
   * resolved upstream via strong identifiers — no need to round-trip through
   * the LLM.
   *
   * If the LLM output also contains a mention with the same label (e.g. it
   * ignored the prompt instruction not to redeclare), the pre-resolved binding
   * wins and the mention is skipped silently.
   */
  preResolved?: Record<string, EntityId>;
  /**
   * Prior facts that were rendered into the prompt for reconciliation. When
   * set, the resolver:
   *   - validates every `update`/`archive` op's `factId` against this set
   *     (rejecting hallucinated ids);
   *   - dispatches the surviving ops through `memory.updateFact`;
   *   - passes `dedup: true` to every `create` op so retries don't duplicate.
   *
   * Leave undefined when calling extraction in fresh (non-reconciliation)
   * mode — the resolver then ignores any `operations` the LLM may have
   * emitted erroneously.
   */
  priorFacts?: IFact[];
  /**
   * Optional skeptic-pass hook. When set, the resolver passes every
   * reconciliation op through this callback before dispatch. Return `true`
   * to accept, `false` to drop (counted as `rejectedSkeptic`). The host
   * normally implements skeptic-pass at a higher level — this hook lets the
   * library route ops through the same gate.
   */
  skepticFilter?: (op: ReconciliationOp) => boolean;
  /**
   * Authoritative observation timestamp for every fact written from this
   * extraction. Set to the SIGNAL'S source date — email `Date` header,
   * calendar event `startTime`, meeting transcript `createdDateTime`, doc
   * `createdAt`. NOT `Date.now()`.
   *
   * When set, this wins over any `observedAt` the LLM may emit and over the
   * `MemorySystem.addFact` `?? now` fallback. It's also used to anchor
   * `validUntil` for ephemeral predicates (`committed_to`, `expressed_concern`,
   * etc.) so commitments observed in historical content expire on the right
   * schedule instead of looking fresh for 90 more days.
   *
   * Strongly recommended whenever extracting from a signal that has a known
   * source timestamp. When undefined, the resolver logs a warning (once per
   * resolver instance) and falls back to the legacy behavior (LLM-emitted
   * observedAt, then `Date.now()`). For reconciliation `update` ops the
   * fallback is `Date.now()` only — `ReconciliationOp` of kind `update`
   * doesn't carry an `observedAt` field, so there's nothing to honor.
   */
  sourceObservedAt?: Date;
}

// =============================================================================
// Metadata label translation
// =============================================================================
//
// The default extraction prompt instructs the LLM to reference other mentions
// by local label inside type-specific metadata fields (e.g. event.attendeeIds,
// task.assigneeId, task.servesAnchorId — see `defaultExtractionPrompt.ts`).
// `upsertEntityBySurface` writes those labels through verbatim, so without
// post-translation entity metadata ends up containing prompt placeholders like
// `'m_self'` / `'m1'` instead of real entity ids — and readers that query by
// these fields (`MemorySystem.resolveRelatedEvents`, task-by-assignee paths)
// silently miss every record.
//
// Translation is keyed by entity type. Adding a new label-bearing metadata
// field → add it here. Values that aren't in `labelToEntityId` are treated as
// unresolved labels and dropped (with an entry in `unresolved[]`) — there is
// no LLM-emitted code path that would put a real entity id here.

interface MetadataLabelFields {
  /** Single-string label fields — translate value or drop if unresolved. */
  readonly single: readonly string[];
  /** Array-of-string label fields — translate each item, drop unresolved. */
  readonly arrays: readonly string[];
}

const TRANSLATABLE_METADATA_FIELDS: Readonly<Record<string, MetadataLabelFields>> = {
  task: { single: ['assigneeId', 'servesAnchorId'], arrays: [] },
  event: { single: [], arrays: ['attendeeIds'] },
};

// =============================================================================
// ExtractionResolver
// =============================================================================

export class ExtractionResolver {
  constructor(private readonly memory: MemorySystem) {}

  /**
   * Latched after the first `sourceObservedAt`-missing warning so callers
   * don't drown logs on steady-state ingest. The first warning is the
   * actionable one; the next 10k aren't.
   */
  private warnedNoSourceObservedAt = false;

  /**
   * Ingest a raw LLM extraction output. Resolves mentions to entities (upsert
   * if missing), translates facts from label-space to id-space, writes them.
   * Attaches `sourceSignalId` to every written fact.
   */
  async resolveAndIngest(
    output: ExtractionOutput,
    sourceSignalId: string,
    scope: ScopeFilter,
    opts?: ExtractionResolverOptions,
  ): Promise<IngestionResult> {
    const entities: IngestionResolvedEntity[] = [];
    const mergeCandidates: IngestionResult['mergeCandidates'] = [];
    const unresolved: IngestionError[] = [];
    const labelToEntityId = new Map<string, EntityId>();

    // observedAt anchoring: without sourceObservedAt the resolver can only
    // honor LLM-emitted dates (rarely supplied) or fall back to Date.now()
    // inside addFact. The latter is dangerous: facts from historical content
    // (old transcript, archived doc, replayed email) get stamped as if
    // observed today, which (a) breaks recency filters and (b) re-anchors
    // the `validUntil` window for ephemeral predicates so commitments from
    // last year look valid for 90 more days. Warn once per resolver instance
    // — the first warning is the actionable signal.
    if (!opts?.sourceObservedAt && !this.warnedNoSourceObservedAt) {
      this.warnedNoSourceObservedAt = true;
      logger.warn(
        {
          component: 'ExtractionResolver.resolveAndIngest',
          sourceSignalId,
        },
        'sourceObservedAt not provided — facts will be stamped with Date.now(). ' +
          'Callers extracting from dated signals (emails, calendar events, ' +
          'transcripts) should pass the signal/content date to anchor observedAt ' +
          'and the validUntil window for ephemeral predicates. ' +
          '(Further occurrences suppressed for this resolver instance.)',
      );
    }

    // ----- Pass 0: pre-resolved bindings (no LLM involvement) -----
    // Seed the label→id map with caller-supplied bindings. If the LLM output
    // later contains a mention for one of these labels, the pre-resolved id
    // wins and the mention is skipped.
    if (opts?.preResolved) {
      for (const [label, entityId] of Object.entries(opts.preResolved)) {
        labelToEntityId.set(label, entityId);
      }
    }

    // ----- Pass 1: mentions → entities -----
    // Resolve in two sub-phases so contextEntityIds can include already-
    // resolved sibling labels (improves disambiguation).
    const mentionEntries = Object.entries(output.mentions ?? {});

    for (const [label, mention] of mentionEntries) {
      // Skip redeclared labels — pre-resolved binding wins defensively.
      if (opts?.preResolved && label in opts.preResolved) {
        continue;
      }
      try {
        const contextEntityIds = [...labelToEntityId.values()];
        const result = await this.memory.upsertEntityBySurface(
          {
            surface: mention.surface,
            type: mention.type,
            identifiers: mention.identifiers ?? [],
            aliases: mention.aliases ?? [],
            contextEntityIds,
            metadata: mention.metadata,
          },
          scope,
          { autoResolveThreshold: opts?.autoResolveThreshold },
        );
        labelToEntityId.set(label, result.entity.id);
        entities.push({
          label,
          entity: result.entity,
          resolved: result.resolved,
          mergeCandidates: result.mergeCandidates,
        });
        if (result.mergeCandidates.length > 0) {
          mergeCandidates.push({
            label,
            surface: mention.surface,
            candidates: result.mergeCandidates,
          });
        }
      } catch (err) {
        unresolved.push({
          where: `mention:${label}`,
          reason: err instanceof Error ? err.message : String(err),
        });
      }
    }

    // ----- Pass 1.5: translate label references inside mention.metadata -----
    // Pass 1 wrote `mention.metadata` verbatim, which means label-bearing
    // fields (event.attendeeIds, task.assigneeId, …) still hold prompt
    // placeholders like 'm_self'/'m1'. Resolve them now that the full
    // labelToEntityId map exists, and patch the entity. Skipping this leaves
    // resolveRelatedEvents and assignee queries blind to LLM-emitted entities.
    //
    // fillMissing semantics: `upsertEntityBySurface` preserves pre-existing
    // metadata fields on resolve. If the entity's current value differs from
    // the LLM's label spec, fillMissing kept a real value — translating would
    // clobber it. Detection: the entity's current metadata field equals the
    // LLM's spec exactly (post-Pass-1) iff Pass 1 wrote the label (new entity
    // OR resolved-with-missing-field). Anything else means real data was
    // preserved; skip translation and surface a note.
    for (const resolved of entities) {
      const meta = output.mentions[resolved.label]?.metadata;
      if (!meta) continue;
      const config = TRANSLATABLE_METADATA_FIELDS[resolved.entity.type];
      if (!config) continue;

      const currentMeta = (resolved.entity.metadata ?? {}) as Record<string, unknown>;
      const patch: Record<string, unknown> = {};
      let changed = false;

      for (const key of config.single) {
        const llmVal = meta[key];
        if (typeof llmVal !== 'string' || llmVal.length === 0) continue;

        if (currentMeta[key] !== llmVal) {
          unresolved.push({
            where: `mention:${resolved.label}.metadata.${key}`,
            reason: `existing entity has different ${key}; LLM-emitted label "${llmVal}" ignored (fillMissing)`,
          });
          continue;
        }

        const id = labelToEntityId.get(llmVal);
        if (id) {
          patch[key] = id;
          changed = true;
        } else {
          // LLM hallucinated a label that has no mention — drop the field.
          // In-memory adapter keeps an `undefined` value here; Mongo strips at
          // BSON serialization. So `'key' in metadata` is unreliable downstream
          // — readers must check `metadata[key] != null`, not `in`.
          patch[key] = undefined;
          changed = true;
          unresolved.push({
            where: `mention:${resolved.label}.metadata.${key}`,
            reason: `metadata label "${llmVal}" not found in mentions (field dropped)`,
          });
        }
      }

      for (const key of config.arrays) {
        const llmArr = meta[key];
        if (!Array.isArray(llmArr)) continue;

        if (!isShallowArrayEqual(currentMeta[key], llmArr)) {
          unresolved.push({
            where: `mention:${resolved.label}.metadata.${key}`,
            reason: `existing entity has different ${key}; LLM-emitted labels ignored (fillMissing)`,
          });
          continue;
        }

        const translated: EntityId[] = [];
        for (const item of llmArr) {
          if (typeof item !== 'string' || item.length === 0) continue;
          const id = labelToEntityId.get(item);
          if (id) {
            translated.push(id);
          } else {
            unresolved.push({
              where: `mention:${resolved.label}.metadata.${key}`,
              reason: `metadata label "${item}" not found in mentions (item dropped)`,
            });
          }
        }
        patch[key] = translated;
        changed = true;
      }

      if (!changed) continue;

      try {
        const updated = await this.memory.updateEntityMetadata(
          resolved.entity.id,
          patch,
          scope,
        );
        // Reflect the patched metadata back into the result so callers don't
        // see the pre-translation state.
        resolved.entity = updated;
      } catch (err) {
        unresolved.push({
          where: `mention:${resolved.label}.metadata`,
          reason: `metadata-label patch failed: ${err instanceof Error ? err.message : String(err)}`,
        });
      }
    }

    // ----- Pass 2: facts → written facts -----
    const writtenFacts: IFact[] = [];
    const factSpecs = output.facts ?? [];
    const hasRegistry = this.memory.hasPredicateRegistry();
    const newPredicatesSet = new Set<string>();

    for (let i = 0; i < factSpecs.length; i++) {
      const spec = factSpecs[i]!;
      try {
        const subjectId = labelToEntityId.get(spec.subject);
        if (!subjectId) {
          unresolved.push({
            where: `fact:${i}`,
            reason: `subject label "${spec.subject}" not found in mentions`,
          });
          continue;
        }
        let objectId: EntityId | undefined;
        if (spec.object) {
          objectId = labelToEntityId.get(spec.object);
          if (!objectId) {
            unresolved.push({
              where: `fact:${i}`,
              reason: `object label "${spec.object}" not found in mentions`,
            });
            continue;
          }
        }

        // Partial context is still useful: drop only the missing labels,
        // keep the fact with whatever resolved. Previously an entire fact was
        // discarded on one missing context label — that silently lost every
        // multi-context fact where the LLM referenced one hallucinated label.
        // Missing labels are still surfaced via `unresolved[]` so the caller
        // can log/tighten the prompt.
        let contextIds: EntityId[] | undefined;
        if (spec.contextIds && spec.contextIds.length > 0) {
          const resolvedIds: EntityId[] = [];
          for (const cid of spec.contextIds) {
            const resolved = labelToEntityId.get(cid);
            if (!resolved) {
              unresolved.push({
                where: `fact:${i}`,
                reason: `context label "${cid}" not found in mentions (dropped from contextIds; fact still written)`,
              });
              continue;
            }
            resolvedIds.push(resolved);
          }
          contextIds = resolvedIds.length > 0 ? resolvedIds : undefined;
        }

        // Canonicalize the predicate and apply the configured H5 drift policy
        // when the result isn't in the registry. Strict-mode rejection happens
        // inside addFact and lands in `unresolved` via the surrounding
        // try/catch.
        let predicate = this.memory.canonicalizePredicate(spec.predicate);
        if (hasRegistry && !this.memory.getPredicateDefinition(predicate)) {
          const decision = this.memory.resolveUnknownPredicate(predicate);
          if (decision.policy === 'drop') {
            unresolved.push({
              where: `fact:${i}`,
              reason: `unknown predicate "${predicate}" — fact dropped (unknownPredicatePolicy='drop')`,
            });
            newPredicatesSet.add(predicate);
            continue;
          }
          if (decision.policy === 'fuzzy_map' && decision.mappedTo) {
            // Record the mapping so operators see "predicate X was snapped
            // onto Y" rather than just "X showed up".
            newPredicatesSet.add(`${predicate}→${decision.mappedTo}`);
            predicate = decision.mappedTo;
          } else {
            // 'keep' or 'fuzzy_map' with no close match — write verbatim.
            newPredicatesSet.add(predicate);
          }
        }

        // Kind validation — the prompt restricts to 'atomic' | 'document',
        // but LLMs hallucinate. Coerce unknown values to 'atomic' and record
        // the drift in `unresolved` (same shape as the newPredicates channel)
        // so callers can monitor and refine the prompt.
        let kind: FactKind;
        if (spec.kind === 'atomic' || spec.kind === 'document') {
          kind = spec.kind;
        } else if (spec.kind === undefined) {
          kind = 'atomic';
        } else {
          kind = 'atomic';
          unresolved.push({
            where: `fact:${i}`,
            reason: `unknown kind "${String(spec.kind)}", coerced to "atomic"`,
          });
        }

        // Auto-route `state_changed` facts on task-type subjects through
        // transitionTaskState so the side effects (metadata state update,
        // stateHistory append, completedAt for terminal states) fire as part
        // of ingestion. Falls back to plain addFact when:
        //  - memory.autoApplyTaskTransitions is false,
        //  - subject isn't a task,
        //  - value shape doesn't have a `to: string` field,
        //  - routing throws (we still want the fact written).
        if (
          predicate === 'state_changed' &&
          this.memory.autoApplyTaskTransitions
        ) {
          const observedAt = opts?.sourceObservedAt ?? toDate(spec.observedAt);
          const routedFact = await this.tryRouteTaskTransition(
            subjectId,
            spec.value,
            {
              signalId: sourceSignalId,
              at: observedAt,
              reason: spec.details,
              // Preserve LLM-supplied fact fields through the state-machine
              // write — without these, the audit fact loses importance /
              // confidence / contextIds / validity that matter for ranking
              // and retrieval.
              factOverrides: {
                importance: spec.importance,
                confidence: spec.confidence,
                contextIds,
                validFrom: toDate(spec.validFrom),
                validUntil: toDate(spec.validUntil),
                summaryForEmbedding: spec.summaryForEmbedding,
                evidenceQuote: spec.evidenceQuote,
              },
            },
            scope,
          );
          if (routedFact !== 'not_task') {
            if (routedFact) writtenFacts.push(routedFact);
            continue;
          }
          // else: subject isn't a task or value malformed — fall through to
          // normal addFact path so the fact still lands.
        }

        const fact = await this.memory.addFact(
          {
            subjectId,
            predicate,
            kind,
            objectId,
            value: spec.value,
            details: spec.details,
            summaryForEmbedding: spec.summaryForEmbedding,
            confidence: spec.confidence,
            importance: spec.importance,
            contextIds,
            // Host-supplied source date wins over LLM-emitted dates — the
            // host knows the signal timestamp authoritatively and the LLM
            // often omits this field (it's not in the default prompt schema).
            // Note: `observedAt` is the SYSTEM observation time, not the
            // event time. Event time belongs on `validFrom`/`validUntil`,
            // so overriding any LLM-emitted observedAt with the signal date
            // is semantically correct even when the LLM mentions a past
            // event from within current content.
            observedAt: opts?.sourceObservedAt ?? toDate(spec.observedAt),
            validFrom: toDate(spec.validFrom),
            validUntil: toDate(spec.validUntil),
            sourceSignalId,
            evidenceQuote: spec.evidenceQuote,
          },
          scope,
        );
        writtenFacts.push(fact);
      } catch (err) {
        unresolved.push({
          where: `fact:${i}`,
          reason: err instanceof Error ? err.message : String(err),
        });
      }
    }

    // ----- Pass 3: reconciliation operations -----
    // When the caller passed `priorFacts`, dispatch any LLM-emitted ops
    // against the existing fact graph. Hallucinated factIds are rejected;
    // surviving ops are routed through addFact / updateFact.
    let operationsApplied: OperationOutcome | undefined;
    if (opts?.priorFacts && (output.operations?.length ?? 0) > 0) {
      operationsApplied = await this.dispatchReconciliationOps(
        output.operations!,
        opts.priorFacts,
        labelToEntityId,
        sourceSignalId,
        scope,
        unresolved,
        opts.skepticFilter,
        opts.sourceObservedAt,
      );
      // Capture newly created facts so callers see them in the result.
      // (dispatchReconciliationOps pushes onto writtenFacts directly.)
    }

    return {
      entities,
      facts: writtenFacts,
      mergeCandidates,
      unresolved,
      newPredicates: Array.from(newPredicatesSet).sort(),
      ...(operationsApplied ? { operationsApplied } : {}),
    };
  }

  /**
   * Dispatch reconciliation ops emitted by the LLM. Returns op-level counts.
   *
   * Validation rules:
   * - `update`/`archive` factId must exist in `priorFacts` (case-sensitive).
   *   Hallucinated ids → counted as `rejectedHallucinated`, op skipped.
   * - `skepticFilter` (when provided) can drop ops with weak evidence → counted
   *   as `rejectedSkeptic`.
   * - `update` with no `newValue` AND no `details` is silently dropped (parser
   *   already filtered, defence in depth).
   *
   * Side effects:
   * - `create` → `memory.addFact({...}, scope)` with `dedup: true` (idempotent
   *   under retry).
   * - `update` → `memory.updateFact(factId, {value|details, observedAt, ...evidence}, scope)`.
   * - `archive` → `memory.updateFact(factId, {archived: true}, scope)`.
   * - Each accepted op appends to `writtenFacts` (creates) / pushes a touched-fact
   *   entry; callers can also read the returned outcome.
   */
  private async dispatchReconciliationOps(
    ops: ReconciliationOp[],
    priorFacts: IFact[],
    labelToEntityId: Map<string, EntityId>,
    sourceSignalId: string,
    scope: ScopeFilter,
    unresolved: IngestionError[],
    skepticFilter: ((op: ReconciliationOp) => boolean) | undefined,
    sourceObservedAt: Date | undefined,
  ): Promise<OperationOutcome> {
    const outcome: OperationOutcome = {
      creates: 0,
      updates: 0,
      archives: 0,
      rejectedHallucinated: 0,
      rejectedSkeptic: 0,
    };
    const priorFactIds = new Set(priorFacts.map((f) => f.id));
    const priorById = new Map(priorFacts.map((f) => [f.id, f]));

    for (let i = 0; i < ops.length; i++) {
      const op = ops[i]!;

      if (skepticFilter && !skepticFilter(op)) {
        outcome.rejectedSkeptic++;
        logger.info(
          {
            component: 'ExtractionResolver.reconcile',
            op: op.op,
            factId: 'factId' in op ? op.factId : undefined,
            sourceSignalId,
          },
          'reconciliation op rejected by skeptic filter',
        );
        continue;
      }

      if (op.op === 'create') {
        try {
          const subjectId = labelToEntityId.get(op.subject);
          if (!subjectId) {
            unresolved.push({
              where: `op:${i}`,
              reason: `create: subject label "${op.subject}" not found in mentions`,
            });
            continue;
          }
          let objectId: EntityId | undefined;
          if (op.objectId) {
            objectId = labelToEntityId.get(op.objectId);
            if (!objectId) {
              unresolved.push({
                where: `op:${i}`,
                reason: `create: objectId label "${op.objectId}" not found in mentions`,
              });
              continue;
            }
          } else if (op.object) {
            objectId = labelToEntityId.get(op.object);
            if (!objectId) {
              unresolved.push({
                where: `op:${i}`,
                reason: `create: object label "${op.object}" not found in mentions`,
              });
              continue;
            }
          }
          const contextIds = op.contextIds
            ?.map((c) => labelToEntityId.get(c))
            .filter((c): c is EntityId => !!c);
          await this.memory.addFact(
            {
              subjectId,
              predicate: op.predicate,
              kind: op.kind,
              objectId,
              value: op.value,
              details: op.details,
              contextIds,
              importance: op.importance,
              confidence: op.confidence,
              sourceSignalId,
              evidenceQuote: op.evidenceQuote,
              observedAt: sourceObservedAt,
              dedup: true,
            },
            scope,
          );
          outcome.creates++;
        } catch (err) {
          unresolved.push({
            where: `op:${i}`,
            reason: `create: ${err instanceof Error ? err.message : String(err)}`,
          });
        }
        continue;
      }

      // update / archive — factId must be a prior fact id.
      if (!priorFactIds.has(op.factId)) {
        outcome.rejectedHallucinated++;
        logger.warn(
          {
            component: 'ExtractionResolver.reconcile',
            op: op.op,
            factId: op.factId,
            sourceSignalId,
          },
          'reconciliation op references unknown factId — rejected',
        );
        unresolved.push({
          where: `op:${i}`,
          reason: `${op.op}: factId "${op.factId}" not in priorFacts (hallucinated)`,
        });
        continue;
      }

      if (op.op === 'update') {
        try {
          const target = priorById.get(op.factId)!;
          // Write newValue to whichever field the original fact used. `value`
          // for atomic facts; `details` for document facts; if both undefined,
          // default to value.
          const patch: Partial<IFact> = {
            // Anchor on the signal's date — extraction time would falsely
            // refresh observedAt on every reprocess of historical content.
            observedAt: sourceObservedAt ?? new Date(),
            sourceSignalId,
          };
          if (op.newValue !== undefined) {
            if (target.kind === 'document' && op.details === undefined) {
              // newValue routed to details for document facts (LLM may pass
              // either field for a doc-kind update; prefer explicit details).
              patch.details = typeof op.newValue === 'string'
                ? op.newValue
                : JSON.stringify(op.newValue);
            } else {
              patch.value = op.newValue;
            }
          }
          if (op.details !== undefined) {
            patch.details = op.details;
          }
          if (op.evidenceQuote !== undefined) {
            patch.evidenceQuote = op.evidenceQuote;
          }
          await this.memory.updateFact(op.factId, patch, scope);
          outcome.updates++;
        } catch (err) {
          unresolved.push({
            where: `op:${i}`,
            reason: `update: ${err instanceof Error ? err.message : String(err)}`,
          });
        }
        continue;
      }

      if (op.op === 'archive') {
        try {
          await this.memory.archiveFact(op.factId, scope);
          outcome.archives++;
        } catch (err) {
          unresolved.push({
            where: `op:${i}`,
            reason: `archive: ${err instanceof Error ? err.message : String(err)}`,
          });
        }
        continue;
      }
    }

    return outcome;
  }

  /**
   * Route a `state_changed` fact through `MemorySystem.transitionTaskState`
   * when the subject is a task entity. Returns:
   *   - The written audit fact on success.
   *   - `null` on success when the transition was a no-op (same state).
   *   - `'not_task'` when routing doesn't apply (subject type != task OR
   *     value shape doesn't carry a `to: string` field) — caller should fall
   *     through to plain `addFact`.
   */
  private async tryRouteTaskTransition(
    subjectId: EntityId,
    value: unknown,
    opts: {
      signalId: string;
      at?: Date;
      reason?: string;
      factOverrides?: {
        importance?: number;
        confidence?: number;
        contextIds?: EntityId[];
        validFrom?: Date;
        validUntil?: Date;
        summaryForEmbedding?: string;
        evidenceQuote?: string;
      };
    },
    scope: ScopeFilter,
  ): Promise<IFact | null | 'not_task'> {
    const subject = await this.memory.getEntity(subjectId, scope);
    if (!subject || subject.type !== 'task') return 'not_task';

    // Expect `{ to: string }` at minimum. The standard predicate example uses
    // `{ from, to }` but we only need `to`.
    const to = extractTo(value);
    if (!to) return 'not_task';

    const result = await this.memory.transitionTaskState(
      subjectId,
      to,
      {
        signalId: opts.signalId,
        at: opts.at,
        reason: opts.reason,
        validate: 'warn',
        factOverrides: opts.factOverrides,
      },
      scope,
    );
    return result.fact;
  }
}

// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------

function toDate(v: string | Date | undefined): Date | undefined {
  if (!v) return undefined;
  if (v instanceof Date) return v;
  const d = new Date(v);
  return Number.isNaN(d.getTime()) ? undefined : d;
}

function extractTo(value: unknown): string | null {
  if (typeof value === 'string' && value.trim().length > 0) return value.trim();
  if (value && typeof value === 'object' && !Array.isArray(value)) {
    const to = (value as Record<string, unknown>).to;
    if (typeof to === 'string' && to.trim().length > 0) return to.trim();
  }
  return null;
}

/**
 * Position-sensitive shallow equality for two arrays. Used in Pass 1.5 to
 * detect whether the entity's current metadata array still matches the LLM's
 * label spec — only then is it safe to translate (otherwise fillMissing has
 * preserved real data and we'd clobber it).
 */
function isShallowArrayEqual(a: unknown, b: readonly unknown[]): boolean {
  if (!Array.isArray(a)) return false;
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) {
    if (a[i] !== b[i]) return false;
  }
  return true;
}
