/**
 * Cross-movement structured memory.
 *
 * Phase 3 carries observations between movements as machine-readable entries
 * (Fact / Decision / OpenQuestion / DoNotRepeat) instead of relying on the
 * `transition.summary` free text. The instance lives for one piece run; each
 * movement reads the snapshot at the start and writes new entries via the
 * `transition.memory_update` field at the end.
 *
 * The schema the LLM submits is intentionally simpler than what we store
 * (no ids, no timestamps, no source movement) — engine fills those in. See
 * `applyMemoryUpdate` for the conversion.
 */

import { logger } from '../../logger.js';

export type Confidence = 'high' | 'medium' | 'low';

/**
 * Phase 5: portability discriminator. Determines whether an entry can be
 * carried across workspace boundaries (subtask spawn / return).
 *   'portable'        — claim is workspace-independent (URL evidence only,
 *                       or no file evidence at all)
 *   'workspace_local' — bound to specific files in the originating workspace;
 *                       carrying it requires re-verification by the consumer
 */
export type Portability = 'portable' | 'workspace_local';

/**
 * Phase 5: evidence kind discriminator. Drives portability inference at
 * fact-construction time.
 *   'none'       — no evidence (LLM stated it without citing)
 *   'url'        — URL evidence (portable across workspaces)
 *   'local_path' — workspace-relative file paths (NOT portable)
 *   'derived'    — computed/inferred (treated as workspace_local conservatively)
 */
export type EvidenceKind = 'none' | 'url' | 'local_path' | 'derived';

/**
 * Phase 5: provenance entry. When a fact / decision is inherited via subtask
 * handoff or delta absorb, the lineage list grows by one entry per crossing.
 * Capped at LINEAGE_MAX_LENGTH to prevent unbounded growth in deep subtask
 * chains; when the cap is hit we keep the root and the most recent entries
 * (the middle is summarized in display).
 */
export interface LineageEntry {
  jobId: string;
  workspaceRelative: string;   // path from the consumer's workspace, e.g. "subtasks/1"
  status: 'success' | 'aborted' | 'needs_user_input';
  deltaId: string;             // handoffId or deltaId of the carrier
}

const LINEAGE_MAX_LENGTH = 10;

export interface Fact {
  id: string;                  // engine-assigned (e.g. "f-1", "f-2", ...)
  claim: string;
  confidence: Confidence;
  evidencePaths: string[];     // workspace-relative paths whose state backs this fact
  evidenceUrls: string[];      // Phase 5: URL evidence (portable across workspaces)
  observedAt: string;          // ISO 8601
  sourceMovement: string;
  portability: Portability;    // Phase 5: workspace-boundary marker
  evidenceKind: EvidenceKind;  // Phase 5: portability inference source
  lineage: LineageEntry[];     // Phase 5: provenance across subtask boundaries
  invalidatedAt?: string;
  invalidationReason?: string;
}

export interface Decision {
  id: string;
  text: string;
  evidencePaths: string[];
  evidenceUrls: string[];      // Phase 5
  decidedAt: string;
  sourceMovement: string;
  portability: Portability;    // Phase 5
  evidenceKind: EvidenceKind;  // Phase 5
  lineage: LineageEntry[];     // Phase 5
  invalidatedAt?: string;
  invalidationReason?: string;
}

export interface OpenQuestion {
  id: string;
  question: string;
  createdAt: string;
  sourceMovement: string;
}

export interface WorkspaceMemorySnapshot {
  facts: Fact[];               // invalidated entries excluded
  decisions: Decision[];       // invalidated entries excluded
  openQuestions: OpenQuestion[];
  doNotRepeat: string[];
}

/**
 * Schema accepted from the LLM via `transition.memory_update` /
 * `complete.memory_update`. All fields optional so existing pieces that
 * don't know about memory still work.
 *
 * Phase 5 added `evidence_urls` so the LLM can cite portable URL evidence
 * separately from workspace-local file paths. Either or both can be
 * provided; the engine derives `evidenceKind` and `portability`.
 */
export interface MemoryUpdatePayload {
  facts?: Array<{
    claim?: unknown;
    evidence_paths?: unknown;
    evidence_urls?: unknown;
    confidence?: unknown;
  }>;
  decisions?: Array<{
    text?: unknown;
    evidence_paths?: unknown;
    evidence_urls?: unknown;
  }>;
  open_questions?: Array<{ question?: unknown }>;
  do_not_repeat?: unknown;
}

const VALID_CONFIDENCE: ReadonlySet<Confidence> = new Set(['high', 'medium', 'low']);

function coerceConfidence(value: unknown): Confidence {
  if (typeof value === 'string' && VALID_CONFIDENCE.has(value as Confidence)) {
    return value as Confidence;
  }
  return 'medium';
}

function coerceStringArray(value: unknown): string[] {
  if (!Array.isArray(value)) return [];
  return value.filter((v): v is string => typeof v === 'string' && v.length > 0);
}

/**
 * Phase 5: derive (evidenceKind, portability) from the evidence inputs.
 * Codex review reflection: be conservative — only `url`-only or `none`
 * become portable; anything with a local path is workspace_local. The
 * caller never overrides this except `cloneWithLineage` (which preserves
 * the original portability when carrying across workspaces).
 */
function inferEvidenceKindAndPortability(input: { evidencePaths: string[]; evidenceUrls: string[] }): { evidenceKind: EvidenceKind; portability: Portability } {
  const hasPaths = input.evidencePaths.length > 0;
  const hasUrls = input.evidenceUrls.length > 0;
  if (hasPaths) return { evidenceKind: 'local_path', portability: 'workspace_local' };
  if (hasUrls) return { evidenceKind: 'url', portability: 'portable' };
  return { evidenceKind: 'none', portability: 'portable' };
}

/**
 * Append a new lineage entry, capping at LINEAGE_MAX_LENGTH. When capped,
 * keep the root (entry 0) and the most recent (cap - 1) entries. The
 * middle is dropped silently — `renderMemorySnapshot` shows "(N entries
 * elided)" in display when it detects the gap.
 */
export function appendLineage(existing: LineageEntry[], next: LineageEntry): LineageEntry[] {
  const combined = [...existing, next];
  if (combined.length <= LINEAGE_MAX_LENGTH) return combined;
  // Keep root + (cap - 1) most recent.
  return [combined[0]!, ...combined.slice(combined.length - (LINEAGE_MAX_LENGTH - 1))];
}

export class WorkspaceMemory {
  private readonly facts: Fact[] = [];
  private readonly decisions: Decision[] = [];
  private readonly openQuestions: OpenQuestion[] = [];
  private readonly doNotRepeat: string[] = [];
  private nextId = 1;

  /**
   * Phase 5: ids of subtask deltas already absorbed into this memory.
   * Persisted by the runtime (piece-runner) to logs/absorbed-deltas.json
   * so re-resume of a parent waiting on subtasks doesn't re-merge the
   * same delta. Codex review flagged this as the #1 implementation
   * landmine.
   */
  private readonly absorbedDeltaIds = new Set<string>();

  private mintId(prefix: string): string {
    return `${prefix}-${this.nextId++}`;
  }

  hasAbsorbedDelta(deltaId: string): boolean {
    return this.absorbedDeltaIds.has(deltaId);
  }

  markDeltaAbsorbed(deltaId: string): void {
    this.absorbedDeltaIds.add(deltaId);
  }

  getAbsorbedDeltaIds(): string[] {
    return Array.from(this.absorbedDeltaIds);
  }

  /** Restore previously-persisted absorbed deltaIds (piece-runner startup). */
  restoreAbsorbedDeltaIds(ids: readonly string[]): void {
    for (const id of ids) this.absorbedDeltaIds.add(id);
  }

  addFact(input: {
    claim: string;
    evidencePaths?: string[];
    evidenceUrls?: string[];
    confidence?: Confidence;
    sourceMovement: string;
    now?: string;
    /** Phase 5: when set, override automatic portability inference (used by handoff/delta absorb). */
    portability?: Portability;
    /** Phase 5: explicit evidenceKind override (used by handoff/delta absorb). */
    evidenceKind?: EvidenceKind;
    /** Phase 5: pre-existing lineage to seed (handoff/delta absorb). */
    lineage?: LineageEntry[];
  }): Fact {
    const evidencePaths = input.evidencePaths ?? [];
    const evidenceUrls = input.evidenceUrls ?? [];
    const inferred = inferEvidenceKindAndPortability({ evidencePaths, evidenceUrls });
    const fact: Fact = {
      id: this.mintId('f'),
      claim: input.claim,
      confidence: input.confidence ?? 'medium',
      evidencePaths,
      evidenceUrls,
      observedAt: input.now ?? new Date().toISOString(),
      sourceMovement: input.sourceMovement,
      portability: input.portability ?? inferred.portability,
      evidenceKind: input.evidenceKind ?? inferred.evidenceKind,
      lineage: input.lineage ?? [],
    };
    this.facts.push(fact);
    return fact;
  }

  addDecision(input: {
    text: string;
    evidencePaths?: string[];
    evidenceUrls?: string[];
    sourceMovement: string;
    now?: string;
    portability?: Portability;
    evidenceKind?: EvidenceKind;
    lineage?: LineageEntry[];
  }): Decision {
    const evidencePaths = input.evidencePaths ?? [];
    const evidenceUrls = input.evidenceUrls ?? [];
    const inferred = inferEvidenceKindAndPortability({ evidencePaths, evidenceUrls });
    const decision: Decision = {
      id: this.mintId('d'),
      text: input.text,
      evidencePaths,
      evidenceUrls,
      decidedAt: input.now ?? new Date().toISOString(),
      sourceMovement: input.sourceMovement,
      portability: input.portability ?? inferred.portability,
      evidenceKind: input.evidenceKind ?? inferred.evidenceKind,
      lineage: input.lineage ?? [],
    };
    this.decisions.push(decision);
    return decision;
  }

  addOpenQuestion(input: { question: string; sourceMovement: string; now?: string }): OpenQuestion {
    const q: OpenQuestion = {
      id: this.mintId('q'),
      question: input.question,
      createdAt: input.now ?? new Date().toISOString(),
      sourceMovement: input.sourceMovement,
    };
    this.openQuestions.push(q);
    return q;
  }

  addDoNotRepeat(item: string): void {
    if (!this.doNotRepeat.includes(item)) {
      this.doNotRepeat.push(item);
    }
  }

  /**
   * Phase 6c: merge-or-add a fact by exact-claim match.
   *   - Existing active fact with same claim → union evidence (paths +
   *     urls), return { merged: true }
   *   - Otherwise → addFact, return { merged: false }
   *
   * Codex review: same-movement claim duplicates were "雑". Exact-match
   * merge unifies behavior with `absorbDelta` (Phase 5).
   */
  mergeOrAddFact(input: Parameters<WorkspaceMemory['addFact']>[0]): { merged: boolean; entry: Fact } {
    const existing = this.facts.find((f) => !f.invalidatedAt && f.claim === input.claim);
    if (existing) {
      for (const p of input.evidencePaths ?? []) {
        if (!existing.evidencePaths.includes(p)) existing.evidencePaths.push(p);
      }
      for (const u of input.evidenceUrls ?? []) {
        if (!existing.evidenceUrls.includes(u)) existing.evidenceUrls.push(u);
      }
      return { merged: true, entry: existing };
    }
    return { merged: false, entry: this.addFact(input) };
  }

  mergeOrAddDecision(input: Parameters<WorkspaceMemory['addDecision']>[0]): { merged: boolean; entry: Decision } {
    const existing = this.decisions.find((d) => !d.invalidatedAt && d.text === input.text);
    if (existing) {
      for (const p of input.evidencePaths ?? []) {
        if (!existing.evidencePaths.includes(p)) existing.evidencePaths.push(p);
      }
      for (const u of input.evidenceUrls ?? []) {
        if (!existing.evidenceUrls.includes(u)) existing.evidenceUrls.push(u);
      }
      return { merged: true, entry: existing };
    }
    return { merged: false, entry: this.addDecision(input) };
  }

  mergeOrAddOpenQuestion(input: Parameters<WorkspaceMemory['addOpenQuestion']>[0]): { merged: boolean; entry: OpenQuestion } {
    const existing = this.openQuestions.find((q) => q.question === input.question);
    if (existing) return { merged: true, entry: existing };
    return { merged: false, entry: this.addOpenQuestion(input) };
  }

  /**
   * Mark every fact / decision whose `evidencePaths` includes `path` as
   * invalidated. Returns the count for logging. Subsequent `snapshot()` calls
   * exclude invalidated entries.
   */
  invalidateByPath(path: string, reason: string, now?: string): number {
    const stamp = now ?? new Date().toISOString();
    let count = 0;
    for (const fact of this.facts) {
      if (!fact.invalidatedAt && fact.evidencePaths.includes(path)) {
        fact.invalidatedAt = stamp;
        fact.invalidationReason = reason;
        count++;
      }
    }
    for (const decision of this.decisions) {
      if (!decision.invalidatedAt && decision.evidencePaths.includes(path)) {
        decision.invalidatedAt = stamp;
        decision.invalidationReason = reason;
        count++;
      }
    }
    return count;
  }

  /**
   * Mark every fact / decision with at least one evidence path as
   * invalidated. Used after Bash, mirroring `ToolResultCache.invalidateAllFiles`.
   * Entries with no `evidencePaths` survive.
   */
  invalidateAllFileEvidence(reason: string, now?: string): number {
    const stamp = now ?? new Date().toISOString();
    let count = 0;
    for (const fact of this.facts) {
      if (!fact.invalidatedAt && fact.evidencePaths.length > 0) {
        fact.invalidatedAt = stamp;
        fact.invalidationReason = reason;
        count++;
      }
    }
    for (const decision of this.decisions) {
      if (!decision.invalidatedAt && decision.evidencePaths.length > 0) {
        decision.invalidatedAt = stamp;
        decision.invalidationReason = reason;
        count++;
      }
    }
    return count;
  }

  snapshot(): WorkspaceMemorySnapshot {
    return {
      facts: this.facts.filter((f) => !f.invalidatedAt),
      decisions: this.decisions.filter((d) => !d.invalidatedAt),
      openQuestions: [...this.openQuestions],
      doNotRepeat: [...this.doNotRepeat],
    };
  }

  /** Total entry counts including invalidated — used for tests and logging. */
  size(): { facts: number; decisions: number; openQuestions: number; doNotRepeat: number } {
    return {
      facts: this.facts.length,
      decisions: this.decisions.length,
      openQuestions: this.openQuestions.length,
      doNotRepeat: this.doNotRepeat.length,
    };
  }

  /**
   * Phase 5: absorb a parent's handoff into this (child) memory. Each fact /
   * decision gets a fresh id and a lineage entry pointing at the parent;
   * portability is **preserved as-is** so workspace_local entries stay
   * workspace_local across the boundary (Codex review: never re-promote).
   *
   * Returns counts so the caller can log or surface them.
   */
  applyHandoff(input: {
    facts: Array<{
      claim: string;
      confidence: Confidence;
      evidencePaths: string[];
      evidenceUrls: string[];
      observedAt: string;
      portability: Portability;
      evidenceKind: EvidenceKind;
      lineage: LineageEntry[];
    }>;
    decisions: Array<{
      text: string;
      evidencePaths: string[];
      evidenceUrls: string[];
      decidedAt: string;
      portability: Portability;
      evidenceKind: EvidenceKind;
      lineage: LineageEntry[];
    }>;
    openQuestions: Array<{ question: string; createdAt: string }>;
    doNotRepeat: string[];
    /** Lineage entry to APPEND describing the boundary crossing. */
    crossingEntry: LineageEntry;
    sourceMovement: string;
  }): { factsAdded: number; decisionsAdded: number; openQuestionsAdded: number; doNotRepeatAdded: number } {
    let factsAdded = 0;
    let decisionsAdded = 0;
    let openQuestionsAdded = 0;
    let doNotRepeatAdded = 0;

    for (const f of input.facts) {
      this.addFact({
        claim: f.claim,
        confidence: f.confidence,
        evidencePaths: f.evidencePaths,
        evidenceUrls: f.evidenceUrls,
        sourceMovement: input.sourceMovement,
        now: f.observedAt,
        portability: f.portability,                            // preserve, never re-promote
        evidenceKind: f.evidenceKind,
        lineage: appendLineage(f.lineage, input.crossingEntry),
      });
      factsAdded++;
    }

    for (const d of input.decisions) {
      this.addDecision({
        text: d.text,
        evidencePaths: d.evidencePaths,
        evidenceUrls: d.evidenceUrls,
        sourceMovement: input.sourceMovement,
        now: d.decidedAt,
        portability: d.portability,
        evidenceKind: d.evidenceKind,
        lineage: appendLineage(d.lineage, input.crossingEntry),
      });
      decisionsAdded++;
    }

    for (const q of input.openQuestions) {
      this.addOpenQuestion({ question: q.question, sourceMovement: input.sourceMovement, now: q.createdAt });
      openQuestionsAdded++;
    }

    for (const item of input.doNotRepeat) {
      const sizeBefore = this.doNotRepeat.length;
      this.addDoNotRepeat(item);
      if (this.doNotRepeat.length > sizeBefore) doNotRepeatAdded++;
    }

    return { factsAdded, decisionsAdded, openQuestionsAdded, doNotRepeatAdded };
  }

  /**
   * Phase 5: absorb a child subtask's memory delta into this (parent)
   * memory. Returns 'skipped' when the deltaId was already absorbed,
   * 'merged' otherwise.
   *
   *   - evidencePaths are rewritten through `rewritePath` so the parent
   *     sees `subtasks/N/output/foo.ts` instead of the child's
   *     workspace-relative `output/foo.ts`. Codex's path-normalize
   *     guarantee (no traversal) is enforced by the caller passing in
   *     a normalize-aware rewriter.
   *   - portability is **forced to workspace_local** for the parent —
   *     even if the child marked something portable, we treat it as
   *     "needs verification in our workspace". Codex review: never
   *     re-promote, always conservative on absorb.
   *   - lineage gets the boundary entry appended.
   *
   * Conflict-merge: if the parent already has a fact with the exact
   * same `claim`, evidencePaths from the delta are merged (set union)
   * into the existing fact rather than creating a duplicate. Decisions
   * follow the same rule by `text`. fuzzy matching is intentionally
   * avoided (Codex review: false positives are worse than duplicates).
   */
  absorbDelta(input: {
    deltaId: string;
    facts: Array<{
      claim: string;
      confidence: Confidence;
      evidencePaths: string[];
      evidenceUrls: string[];
      observedAt: string;
      portability: Portability;
      evidenceKind: EvidenceKind;
      lineage: LineageEntry[];
    }>;
    decisions: Array<{
      text: string;
      evidencePaths: string[];
      evidenceUrls: string[];
      decidedAt: string;
      portability: Portability;
      evidenceKind: EvidenceKind;
      lineage: LineageEntry[];
    }>;
    openQuestions: Array<{ question: string; createdAt: string }>;
    doNotRepeat: string[];
    /** Boundary lineage entry (the child→parent crossing). */
    crossingEntry: LineageEntry;
    /** Function that rewrites a child-relative path to a parent-relative
     * path; throws on traversal. Caller passes a normalize-aware rewriter. */
    rewritePath: (childPath: string) => string;
    sourceMovement: string;
  }): { kind: 'skipped'; reason: string } | { kind: 'merged'; counts: { factsAdded: number; factsMerged: number; decisionsAdded: number; decisionsMerged: number; openQuestionsAdded: number; doNotRepeatAdded: number; pathsDropped: number } } {
    if (this.absorbedDeltaIds.has(input.deltaId)) {
      return { kind: 'skipped', reason: `deltaId="${input.deltaId}" already absorbed` };
    }

    let factsAdded = 0;
    let factsMerged = 0;
    let decisionsAdded = 0;
    let decisionsMerged = 0;
    let openQuestionsAdded = 0;
    let doNotRepeatAdded = 0;
    let pathsDropped = 0;

    const rewriteAll = (paths: string[]): string[] => {
      const out: string[] = [];
      for (const p of paths) {
        try {
          out.push(input.rewritePath(p));
        } catch {
          pathsDropped++;
        }
      }
      return out;
    };

    for (const f of input.facts) {
      const rewrittenPaths = rewriteAll(f.evidencePaths);
      const existing = this.facts.find((x) => !x.invalidatedAt && x.claim === f.claim);
      if (existing) {
        // Merge evidence into the existing fact.
        for (const p of rewrittenPaths) {
          if (!existing.evidencePaths.includes(p)) existing.evidencePaths.push(p);
        }
        for (const u of f.evidenceUrls) {
          if (!existing.evidenceUrls.includes(u)) existing.evidenceUrls.push(u);
        }
        // Append the boundary lineage to the existing fact too — the
        // claim is now corroborated from a second source.
        existing.lineage = appendLineage(existing.lineage, input.crossingEntry);
        factsMerged++;
      } else {
        this.addFact({
          claim: f.claim,
          confidence: f.confidence,
          evidencePaths: rewrittenPaths,
          evidenceUrls: f.evidenceUrls,
          sourceMovement: input.sourceMovement,
          now: f.observedAt,
          portability: 'workspace_local',                       // force on absorb
          evidenceKind: rewrittenPaths.length > 0 ? 'local_path' : (f.evidenceUrls.length > 0 ? 'url' : 'none'),
          lineage: appendLineage(f.lineage, input.crossingEntry),
        });
        factsAdded++;
      }
    }

    for (const d of input.decisions) {
      const rewrittenPaths = rewriteAll(d.evidencePaths);
      const existing = this.decisions.find((x) => !x.invalidatedAt && x.text === d.text);
      if (existing) {
        for (const p of rewrittenPaths) {
          if (!existing.evidencePaths.includes(p)) existing.evidencePaths.push(p);
        }
        for (const u of d.evidenceUrls) {
          if (!existing.evidenceUrls.includes(u)) existing.evidenceUrls.push(u);
        }
        existing.lineage = appendLineage(existing.lineage, input.crossingEntry);
        decisionsMerged++;
      } else {
        this.addDecision({
          text: d.text,
          evidencePaths: rewrittenPaths,
          evidenceUrls: d.evidenceUrls,
          sourceMovement: input.sourceMovement,
          now: d.decidedAt,
          portability: 'workspace_local',
          evidenceKind: rewrittenPaths.length > 0 ? 'local_path' : (d.evidenceUrls.length > 0 ? 'url' : 'none'),
          lineage: appendLineage(d.lineage, input.crossingEntry),
        });
        decisionsAdded++;
      }
    }

    for (const q of input.openQuestions) {
      this.addOpenQuestion({ question: q.question, sourceMovement: input.sourceMovement, now: q.createdAt });
      openQuestionsAdded++;
    }

    for (const item of input.doNotRepeat) {
      const sizeBefore = this.doNotRepeat.length;
      this.addDoNotRepeat(item);
      if (this.doNotRepeat.length > sizeBefore) doNotRepeatAdded++;
    }

    this.absorbedDeltaIds.add(input.deltaId);

    return {
      kind: 'merged',
      counts: { factsAdded, factsMerged, decisionsAdded, decisionsMerged, openQuestionsAdded, doNotRepeatAdded, pathsDropped },
    };
  }
}

export interface ApplyMemoryUpdateResult {
  factsAdded: number;
  factsMerged: number;
  decisionsAdded: number;
  decisionsMerged: number;
  openQuestionsAdded: number;
  openQuestionsMerged: number;
  doNotRepeatAdded: number;
  rejected: number;
}

/**
 * Apply an LLM-submitted memory_update payload to the given memory. Skips
 * malformed entries (missing required strings) but never throws so a bad LLM
 * response can't kill a movement.
 *
 * Phase 6c: same-movement `claim` exact-match dedup. When the LLM submits
 * a claim that's already present (active, not invalidated) in the memory,
 * the new evidence is union-merged into the existing entry instead of
 * creating a duplicate. Reported separately as `factsMerged` so callers
 * can surface "0 new, 3 reinforced" in tool-result text.
 */
export function applyMemoryUpdate(
  memory: WorkspaceMemory,
  payload: MemoryUpdatePayload | undefined,
  sourceMovement: string,
  now?: string,
): ApplyMemoryUpdateResult {
  const empty: ApplyMemoryUpdateResult = {
    factsAdded: 0, factsMerged: 0,
    decisionsAdded: 0, decisionsMerged: 0,
    openQuestionsAdded: 0, openQuestionsMerged: 0,
    doNotRepeatAdded: 0,
    rejected: 0,
  };
  if (!payload || typeof payload !== 'object') return empty;

  const result: ApplyMemoryUpdateResult = { ...empty };

  if (Array.isArray(payload.facts)) {
    for (const raw of payload.facts) {
      if (!raw || typeof raw !== 'object' || typeof raw.claim !== 'string' || raw.claim.length === 0) {
        result.rejected++;
        continue;
      }
      const outcome = memory.mergeOrAddFact({
        claim: raw.claim,
        evidencePaths: coerceStringArray(raw.evidence_paths),
        evidenceUrls: coerceStringArray(raw.evidence_urls),
        confidence: coerceConfidence(raw.confidence),
        sourceMovement,
        now,
      });
      if (outcome.merged) result.factsMerged++;
      else result.factsAdded++;
    }
  }

  if (Array.isArray(payload.decisions)) {
    for (const raw of payload.decisions) {
      if (!raw || typeof raw !== 'object' || typeof raw.text !== 'string' || raw.text.length === 0) {
        result.rejected++;
        continue;
      }
      const outcome = memory.mergeOrAddDecision({
        text: raw.text,
        evidencePaths: coerceStringArray(raw.evidence_paths),
        evidenceUrls: coerceStringArray(raw.evidence_urls),
        sourceMovement,
        now,
      });
      if (outcome.merged) result.decisionsMerged++;
      else result.decisionsAdded++;
    }
  }

  if (Array.isArray(payload.open_questions)) {
    for (const raw of payload.open_questions) {
      if (!raw || typeof raw !== 'object' || typeof raw.question !== 'string' || raw.question.length === 0) {
        result.rejected++;
        continue;
      }
      const outcome = memory.mergeOrAddOpenQuestion({ question: raw.question, sourceMovement, now });
      if (outcome.merged) result.openQuestionsMerged++;
      else result.openQuestionsAdded++;
    }
  }

  for (const item of coerceStringArray(payload.do_not_repeat)) {
    const sizeBefore = memory.size().doNotRepeat;
    memory.addDoNotRepeat(item);
    if (memory.size().doNotRepeat > sizeBefore) result.doNotRepeatAdded++;
  }

  if (result.rejected > 0) {
    logger.warn(`[workspace-memory] rejected ${result.rejected} malformed memory_update entr${result.rejected === 1 ? 'y' : 'ies'} from movement=${sourceMovement}`);
  }

  return result;
}

/** Total count of entries that produced a memory mutation (for empty-payload detection). */
export function memoryUpdateAppliedTotal(r: ApplyMemoryUpdateResult): number {
  return r.factsAdded + r.factsMerged + r.decisionsAdded + r.decisionsMerged + r.openQuestionsAdded + r.openQuestionsMerged + r.doNotRepeatAdded;
}

const MAX_FACTS_IN_PROMPT = 20;
const MAX_DECISIONS_IN_PROMPT = 10;
const MAX_OPEN_QUESTIONS_IN_PROMPT = 10;
const MAX_DO_NOT_REPEAT_IN_PROMPT = 10;
const MAX_CLAIM_DISPLAY_CHARS = 200;

function truncate(text: string, max: number): string {
  return text.length <= max ? text : `${text.slice(0, max)}…`;
}

function renderEvidenceRefs(paths: string[], urls: string[] = []): string {
  const parts: string[] = [];
  if (paths.length > 0) {
    const head = paths.slice(0, 3).join(', ');
    const suffix = paths.length > 3 ? ` +${paths.length - 3}件` : '';
    parts.push(`paths: ${head}${suffix}`);
  }
  if (urls.length > 0) {
    const head = urls.slice(0, 2).join(', ');
    const suffix = urls.length > 2 ? ` +${urls.length - 2}件` : '';
    parts.push(`urls: ${head}${suffix}`);
  }
  if (parts.length === 0) return '';
  return ` [evidence: ${parts.join('; ')}]`;
}

/**
 * Phase 5: build the prefix tags that surface trust/provenance to the LLM.
 *   - workspace_local fact (own piece): "[要再検証]" hint
 *   - inherited fact (lineage non-empty): "[他 workspace 由来]" + path hint
 *   - inherited via aborted ancestor: "[低信頼]" hint
 *
 * Multiple tags concatenate with a leading space.
 */
function renderPortabilityLineageTags(entry: { portability: Portability; lineage: LineageEntry[] }): string {
  const tags: string[] = [];
  if (entry.lineage.length > 0) {
    const last = entry.lineage[entry.lineage.length - 1]!;
    if (last.status === 'aborted') {
      tags.push('低信頼');
    }
    const hops = entry.lineage.length;
    if (hops === 1) {
      tags.push(`他 workspace 由来: ${last.workspaceRelative}`);
    } else {
      // For deep lineage, show root + last (matches the lineage cap policy
      // in appendLineage which keeps root + recent).
      const root = entry.lineage[0]!;
      tags.push(`他 workspace 由来: ${root.workspaceRelative}→…→${last.workspaceRelative} (${hops} hops)`);
    }
  }
  if (entry.portability === 'workspace_local') {
    tags.push('要再検証');
  }
  if (tags.length === 0) return '';
  return ` [${tags.join(' / ')}]`;
}

/**
 * Render the snapshot as a compact Markdown block to inject into the next
 * movement's system prompt. Returns empty string if the snapshot has nothing
 * to show, so the caller can drop the section header entirely.
 *
 * Phase 5: facts/decisions surface portability + lineage cues so the LLM
 * treats inherited workspace_local entries as needs-re-verification rather
 * than as established truth.
 */
export function renderMemorySnapshot(snapshot: WorkspaceMemorySnapshot): string {
  const sections: string[] = [];

  if (snapshot.facts.length > 0) {
    const recent = snapshot.facts.slice(-MAX_FACTS_IN_PROMPT);
    const dropped = snapshot.facts.length - recent.length;
    const lines = recent.map((f) => `- [${f.sourceMovement}] (${f.confidence}) ${truncate(f.claim, MAX_CLAIM_DISPLAY_CHARS)}${renderPortabilityLineageTags(f)}${renderEvidenceRefs(f.evidencePaths, f.evidenceUrls)}`);
    const header = dropped > 0
      ? `### 確立した事実 (${recent.length}/${snapshot.facts.length}件、古い ${dropped} 件は省略)`
      : `### 確立した事実 (${recent.length}件)`;
    sections.push(`${header}\n${lines.join('\n')}`);
  }

  if (snapshot.decisions.length > 0) {
    const recent = snapshot.decisions.slice(-MAX_DECISIONS_IN_PROMPT);
    const lines = recent.map((d) => `- [${d.sourceMovement}] ${truncate(d.text, MAX_CLAIM_DISPLAY_CHARS)}${renderPortabilityLineageTags(d)}${renderEvidenceRefs(d.evidencePaths, d.evidenceUrls)}`);
    sections.push(`### 決定 (${recent.length}件)\n${lines.join('\n')}`);
  }

  if (snapshot.openQuestions.length > 0) {
    const recent = snapshot.openQuestions.slice(-MAX_OPEN_QUESTIONS_IN_PROMPT);
    const lines = recent.map((q) => `- [${q.sourceMovement}] ${truncate(q.question, MAX_CLAIM_DISPLAY_CHARS)}`);
    sections.push(`### 未解決の問い (${recent.length}件)\n${lines.join('\n')}`);
  }

  if (snapshot.doNotRepeat.length > 0) {
    const recent = snapshot.doNotRepeat.slice(-MAX_DO_NOT_REPEAT_IN_PROMPT);
    const lines = recent.map((item) => `- ${truncate(item, MAX_CLAIM_DISPLAY_CHARS)}`);
    sections.push(`### 繰り返し禁止 (${recent.length}件)\n${lines.join('\n')}`);
  }

  if (sections.length === 0) return '';

  const policy = '※ memory は再調査禁止の根拠ではなく、再調査判断の入力です。低 confidence の事実、Edit/Bash 後に陳腐化した可能性のある事実、[要再検証] / [他 workspace 由来] / [低信頼] タグ付きの事実は再確認してください。';
  return `## これまでに蓄積した観測\n${sections.join('\n\n')}\n\n${policy}`;
}
