import type { ChatMessage, CondensePolicy, CondenseTrigger } from '@genesislcap/foundation-ai';

/**
 * Tool-context condensation: collapse stale tool payloads out of the history
 * sent to the model, while leaving stored history untouched.
 *
 * The driver owns the registry (tool handlers populate it via `condenseWhen`)
 * and the cadence (this runs before every provider call). This module owns the
 * pure application: given the registry + the current loop iteration, produce a
 * rewritten copy of the history with stale args/results replaced by stubs. Kept
 * pure (no driver/session state, no logging) so it unit-tests in isolation —
 * the one-time `context.condensed` signal is delivered via an `onCondensed`
 * callback rather than reaching into the debug-event log directly.
 *
 * @packageDocumentation
 */

/**
 * Below this serialized size a tool payload isn't worth condensing — the stub
 * would save little and cost a breadcrumb. A driver-level floor so the public
 * `condenseWhen` API needs no per-policy threshold field.
 */
export const CONDENSE_MIN_CHARS = 1000;

/** Key the collapsed args are stored under — a tool-call's args must stay a Record. */
export const CONDENSED_ARGS_KEY = 'condensed';

/**
 * Rough chars-per-token divisor for the `tokensSaved` estimate. No tokenizer is
 * available in this stack; ~4 chars/token is the usual English approximation.
 */
const APPROX_CHARS_PER_TOKEN = 4;

/**
 * A `condenseWhen` policy registered against a tool-call id, with its age clock
 * and report-once flags. The driver holds these in a `Map<toolCallId, …>`;
 * {@link applyCondensation} reads them and flips the `reported*` flags so the
 * `context.condensed` signal fires once per payload, not once per provider call.
 */
export interface RegisteredCondensePolicy {
  policy: CondensePolicy;
  /**
   * Monotonic model-call sequence (`modelCallSeq`) when `condenseWhen` was called
   * — the origin of the age clock. Driver-lifetime and NOT reset per turn, so age
   * is measured consistently across turn boundaries (the field name is historical;
   * it is not the per-turn loop iteration).
   */
  iteration: number;
  /** Turn (`sendMessage`) sequence when the call was made — the `turnEnd` clock. */
  turn: number;
  /** Agent-activation sequence when the call was made — the `agentEnd` clock. */
  activation: number;
  /** App-controlled phase epoch when the call was made — the `phaseEnd` clock. */
  phaseEpoch: number;
  /** `context.condensed` already emitted for the args payload (fire-once). */
  reportedArgs?: boolean;
  /** `context.condensed` already emitted for the response payload (fire-once). */
  reportedResponse?: boolean;
}

/**
 * The driver's live state at the moment of a provider call — the clocks every
 * trigger is evaluated against. Passed by the driver; kept as an interface so the
 * transform stays a pure function of (history, policies, clocks).
 */
export interface CondenseContext {
  /** Monotonic model-call sequence now — the `age` clock. */
  modelCall: number;
  /** Monotonic turn (`sendMessage`) sequence now — the `turnEnd` clock. */
  turn: number;
  /** Whether the agent activation that made a call has since ended — the `agentEnd` clock. */
  activationEnded: (activation: number) => boolean;
  /**
   * Whether a later phase epoch is current than the one a call was made in — the
   * `phaseEnd` clock. True once `endPhase()` has been called since the call.
   */
  phaseEnded: (phaseEpoch: number) => boolean;
}

/**
 * Structured detail for a `context.condensed` meta-event. The index signature
 * lets it satisfy `recordMetaEvent`'s `Record<string, unknown>` detail param
 * while the named fields stay strictly typed.
 */
export interface CondensedEventDetail {
  [key: string]: unknown;
  /** Tool-loop iteration the collapsed call was made on. */
  turn: number;
  toolCallId: string;
  tool: string;
  target: 'args' | 'response';
  /** Human-readable trigger, e.g. `superseded:Foo.tsx` or `age:3`. */
  trigger: string;
  /** Length of the replacement stub. */
  stubLen: number;
  /** Estimated input tokens saved this collapse (char/4 — no tokenizer in-stack). */
  tokensSaved: number;
}

/** Short human-readable reason for the stub text, per fired trigger. */
function triggerReason(trigger: CondenseTrigger): string {
  switch (trigger.kind) {
    case 'age':
      return 'aged out';
    case 'turnEnd':
      return 'turn ended';
    case 'agentEnd':
      return 'agent finished';
    case 'phaseEnd':
      return 'phase ended';
    default:
      return 'superseded';
  }
}

/** Framework-generated `pointer` stub — advertises that re-calling restores the content. */
function condenseStub(
  target: 'args' | 'response',
  tool: string,
  trigger: CondenseTrigger,
  origLen: number,
): string {
  const what = target === 'args' ? 'args' : 'result';
  const key = trigger.kind === 'superseded' ? ` ${trigger.by}` : '';
  return `[${tool}${key} — ${what} elided, ~${origLen} chars (${triggerReason(trigger)}); re-call to restore]`;
}

/** Stable label for the `context.condensed` meta-event's `trigger` field. */
function triggerLabel(trigger: CondenseTrigger): string {
  switch (trigger.kind) {
    case 'age':
      return `age:${trigger.turns}`;
    case 'superseded':
      return `superseded:${trigger.by}`;
    default:
      return trigger.kind; // 'turnEnd' | 'agentEnd' | 'phaseEnd'
  }
}

function estimateTokensSaved(origLen: number, stubLen: number): number {
  return Math.max(0, Math.ceil((origLen - stubLen) / APPROX_CHARS_PER_TOKEN));
}

/**
 * Collapse stale tool payloads (declared via `condenseWhen`) out of the
 * model-bound history. Pure over the `history` array — it emits new message
 * objects and never mutates the input messages (mirroring the agent-masking
 * transform). It DOES, however, flip the `reported*` flag on the matching
 * `policies` entry the first time a payload collapses — the report-once gate that
 * keeps `onCondensed` firing once per (tool call, payload) even though the
 * collapse itself re-runs before every provider call.
 *
 * Triggers (a policy may list several via `on: Trigger[]` — the FIRST to fire
 * collapses the payload; all are monotonic, so the collapse never reverts):
 * - `superseded` — among all registered calls sharing a `by` key, the LAST in
 *   history order survives; every earlier one's targeted payload collapses. A
 *   re-call with the same key becomes the new survivor and re-arms the rest.
 * - `age` — the result is first visible one model-call after the call, so `turns`
 *   is how many model-calls may see the full result before it collapses
 *   (fires when `modelCall − callModelCall > turns`): `turns: 1` is seen once,
 *   then collapses. The clock is monotonic across turns.
 * - `turnEnd` — collapses once the turn (`sendMessage`) that made the call has
 *   ended (fires when `turn > callTurn`): full for the rest of that request,
 *   gone on every later one.
 * - `agentEnd` — collapses once the agent activation that made the call has ended
 *   (a swap to another agent, or `releaseAgent`/`completeSubAgent`): kept across
 *   all of the agent's turns, dropped when its flow finishes.
 * - `phaseEnd` — collapses once the app advances its phase epoch via `endPhase()`
 *   after the call (a sub-`agentEnd` boundary the author declares), OR at
 *   `agentEnd` as a backstop if no boundary is ever declared.
 *
 * The tool-call/result envelope is never removed (providers reject orphaned
 * calls/results) — only the args object or the result content is replaced.
 *
 * @param history - The to-model history copy to rewrite (not mutated).
 * @param policies - The driver's registry, keyed by tool-call id. Its entries'
 *   `reported*` flags are flipped as payloads first collapse.
 * @param ctx - The driver's live clocks (model-call / turn / agent-activation).
 * @param onCondensed - Invoked once per payload at its full→stub transition.
 */
export function applyCondensation(
  history: ChatMessage[],
  policies: Map<string, RegisteredCondensePolicy>,
  ctx: CondenseContext,
  onCondensed: (detail: CondensedEventDetail) => void,
): ChatMessage[] {
  if (policies.size === 0) return history;

  const triggersOf = (entry: RegisteredCondensePolicy): CondenseTrigger[] =>
    Array.isArray(entry.policy.on) ? entry.policy.on : [entry.policy.on];

  // One pass: resolve supersession survivors and a toolCallId → tool-name
  // lookup. For each `superseded` key the LAST call in history order survives.
  // The name lookup lets a tool message (which carries only an id) name its tool
  // in stubs and events.
  const latestByKey = new Map<string, string>();
  const nameById = new Map<string, string>();
  for (const msg of history) {
    if (!msg.toolCalls?.length) continue;
    for (const tc of msg.toolCalls) {
      nameById.set(tc.id, tc.name);
      const entry = policies.get(tc.id);
      if (!entry) continue;
      for (const t of triggersOf(entry)) {
        if (t.kind === 'superseded') latestByKey.set(t.by, tc.id);
      }
    }
  }

  const triggerFires = (
    toolCallId: string,
    entry: RegisteredCondensePolicy,
    trig: CondenseTrigger,
  ): boolean => {
    switch (trig.kind) {
      // Result first visible one model-call after the call, so collapse one call
      // past the last allowed view: turns:1 → seen once, then gone. Monotonic
      // clock, so this holds across turn boundaries too.
      case 'age':
        return ctx.modelCall - entry.iteration > trig.turns;
      case 'turnEnd':
        return ctx.turn > entry.turn;
      case 'agentEnd':
        return ctx.activationEnded(entry.activation);
      // Fires when the app advanced the phase epoch since the call — OR, as a
      // backstop, when the whole activation has ended, so a phase boundary that
      // is never declared still collapses at `agentEnd` rather than never.
      case 'phaseEnd':
        return ctx.phaseEnded(entry.phaseEpoch) || ctx.activationEnded(entry.activation);
      default:
        return latestByKey.get(trig.by) !== toolCallId; // superseded by a newer call
    }
  };

  // The first listed trigger that fires — drives the collapse, the stub reason,
  // and the event label. `undefined` means the payload stays full.
  const firstFired = (
    toolCallId: string,
    entry: RegisteredCondensePolicy,
  ): CondenseTrigger | undefined =>
    triggersOf(entry).find((t) => triggerFires(toolCallId, entry, t));

  return history.map((msg) => {
    // Tool-call ARGS live on the assistant message.
    if (msg.toolCalls?.length) {
      let changed = false;
      const toolCalls = msg.toolCalls.map((tc) => {
        const entry = policies.get(tc.id);
        if (!entry?.policy.args) return tc;
        const fired = firstFired(tc.id, entry);
        if (!fired) return tc;
        const origLen = JSON.stringify(tc.args ?? {}).length;
        if (origLen < CONDENSE_MIN_CHARS) return tc;
        changed = true;
        const result = entry.policy.args;
        // Args must stay a Record; `drop` is an empty object, otherwise stash the
        // stub under a single key. Spread `tc` so providerMetadata (e.g. the
        // Gemini reasoning signature that must round-trip) and UI fields survive.
        const args: Record<string, unknown> =
          result === 'drop'
            ? {}
            : {
                [CONDENSED_ARGS_KEY]:
                  result === 'pointer'
                    ? condenseStub('args', tc.name, fired, origLen)
                    : result.replaceWith,
              };
        if (!entry.reportedArgs) {
          entry.reportedArgs = true;
          const stubLen = JSON.stringify(args).length;
          onCondensed({
            turn: entry.iteration,
            toolCallId: tc.id,
            tool: tc.name,
            target: 'args',
            trigger: triggerLabel(fired),
            stubLen,
            tokensSaved: estimateTokensSaved(origLen, stubLen),
          });
        }
        return { ...tc, args };
      });
      return changed ? { ...msg, toolCalls } : msg;
    }

    // The tool RESULT lives on the tool message.
    if (msg.toolResult) {
      const entry = policies.get(msg.toolResult.toolCallId);
      // Defensive `?? 0` for restored/malformed history — `content` is typed
      // `string`, but a 0-length payload is simply below the floor and skipped.
      const origLen = msg.toolResult.content?.length ?? 0;
      const fired = entry?.policy.response
        ? firstFired(msg.toolResult.toolCallId, entry)
        : undefined;
      if (entry && fired && origLen >= CONDENSE_MIN_CHARS) {
        const tool = nameById.get(msg.toolResult.toolCallId) ?? msg.toolResult.toolCallId;
        const result = entry.policy.response!; // `fired` is only set when response is declared
        // Kept non-empty — Anthropic rejects empty tool_result content.
        const content =
          result === 'drop'
            ? '[elided]'
            : result === 'pointer'
              ? condenseStub('response', tool, fired, origLen)
              : result.replaceWith;
        if (!entry.reportedResponse) {
          entry.reportedResponse = true;
          onCondensed({
            turn: entry.iteration,
            toolCallId: msg.toolResult.toolCallId,
            tool,
            target: 'response',
            trigger: triggerLabel(fired),
            stubLen: content.length,
            tokensSaved: estimateTokensSaved(origLen, content.length),
          });
        }
        return { ...msg, toolResult: { ...msg.toolResult, content } };
      }
    }

    return msg;
  });
}
