/**
 * Run lifecycle types — the neutral home for what a "run" is.
 *
 * Shared by `@tanstack/ai-persistence` (which exposes a `runs` store through
 * `withPersistence`) and `@tanstack/ai-sandbox` (whose run driver records run
 * status). Living in core is what lets one `RunRecord` per run be shared by
 * both, instead of each package keeping its own and disagreeing. Same rationale
 * as `LockStore` (`packages/ai/src/locks.ts`), which is likewise a
 * coordination primitive that core owns so that no consumer package has to.
 */
import { createCapability } from './capabilities'
import type { TokenUsage } from '../../../types'

/** A terminal run status: no further events will be appended. */
export type TerminalRunStatus = 'completed' | 'failed' | 'aborted'

/**
 * Lifecycle status of one run (one agent turn within a conversation).
 *
 * `interrupted` is a human-in-the-loop PAUSE that interrupt-resume continues
 * from — it is deliberately NOT terminal, and must never be conflated with
 * `aborted` (an explicit cancellation).
 *
 * The two are now written by different hooks and cannot be confused:
 *
 * - `'interrupted'` is written ONLY by `withPersistence`'s `onInterrupt`, and
 *   carries NO `finishedAt` (a non-terminal status has not finished).
 * - `'aborted'` is written by `withPersistence`'s `onAbort`, and only for an
 *   abort that is an explicit cancel or that is ending the run for good.
 * - A mere client disconnect on a run with durable storage wired writes
 *   NEITHER: the record stays `'running'` and gains `detachedSince`, because the
 *   agent is still running and a later attach can take it over.
 *
 * Intent is never inferred from the abort itself — see `RUN_CANCEL_REASON` and
 * `requestRunCancel` in `../cancel`.
 */
export type RunStatus = 'running' | 'interrupted' | TerminalRunStatus

// A Record keyed by the union is exhaustiveness-checked: adding a member to
// TerminalRunStatus is a compile error here until this map is updated. A
// `Set<RunStatus>` would silently answer `false` for the new member instead.
const TERMINAL: Record<TerminalRunStatus, true> = {
  completed: true,
  failed: true,
  aborted: true,
}

// Same exhaustiveness trick over the FULL union, for {@link isRunStatus}.
const ALL_STATUSES: Record<RunStatus, true> = {
  running: true,
  interrupted: true,
  completed: true,
  failed: true,
  aborted: true,
}

/**
 * Whether `value` is a {@link RunStatus} — the guard a backend validates a row
 * with at DESERIALIZATION.
 *
 * `RunStatus` is a compile-time claim about a storage column. A row arrives as
 * JSON out of D1, a Durable Object, or Postgres, and nothing in the type system
 * checked what that column actually held, so a `RunStore` implementation should
 * run its row's `status` through this before handing the record on. The readers
 * downstream act DESTRUCTIVELY on the answer — `@tanstack/ai-sandbox`'s journal
 * sweep DELETES the journal of a run it believes terminal — so a row that lies
 * about its status is not a display bug.
 */
export function isRunStatus(value: unknown): value is RunStatus {
  return typeof value === 'string' && Object.hasOwn(ALL_STATUSES, value)
}

/**
 * Whether `status` means no further events will be appended. Narrows, so a
 * caller inside the guard can pass `status` where a {@link TerminalRunStatus}
 * is required without a cast.
 *
 * `Object.hasOwn`, never `in`: `in` walks the prototype chain, so a row whose
 * `status` column held `'toString'` or `'constructor'` would be reported
 * terminal. `status` is TYPED `RunStatus`, but every value reaching here comes
 * off a user-implemented {@link RunStore} and the type is only a claim (see
 * {@link isRunStatus}). A false `true` deletes a live run's journal
 * (`@tanstack/ai-sandbox`'s journal sweep), fails its attach as `'terminal-run'`
 * (`attach-preflight`), and refuses to drive it (`stream-to-response.ts`).
 */
export function isTerminalRunStatus(
  status: RunStatus,
): status is TerminalRunStatus {
  return Object.hasOwn(TERMINAL, status)
}

/**
 * Why a run failed.
 *
 * A bare message is an LLM provider's prose: it changes between model
 * versions and cannot be branched on. `code` is what a consumer switches over
 * to decide whether to retry, escalate, or surface a specific UI.
 */
export interface RunError {
  message: string
  /** Stable, machine-branchable classification, when the provider supplies one. */
  code?: string
}

/** Durable bookkeeping for a single run. */
export interface RunRecord {
  runId: string
  /**
   * Conversation this run belongs to — the `Scope.threadId`.
   *
   * Generation jobs (a one-shot `generate()` with no conversation) must not
   * reuse this record by faking `threadId = requestId`; they need a separate
   * job store. `withGenerationPersistence` currently does exactly that and
   * labels itself a stopgap — do not copy it.
   */
  threadId: string
  status: RunStatus
  startedAt: number
  finishedAt?: number
  error?: RunError
  usage?: TokenUsage
  /**
   * Compound sandbox key this run was bound to, when it ran in a sandbox.
   * Recorded so a future reclaimer can identify the sandbox to tear down
   * without re-deriving the key. Written by `withSandbox`'s detach path
   * (`onAbort` in `@tanstack/ai-sandbox`'s `middleware.ts`) at the same time as
   * `detachedSince`, when a disconnect leaves the run detached rather than
   * destroying the sandbox. A backend must round-trip this field — see
   * `listReclaimable` below for who eventually reads it.
   */
  sandboxKey?: string
  /**
   * Epoch ms when the last viewer detached; absent while someone is attached.
   * Written by `withSandbox`'s detach path (`onAbort` in `@tanstack/ai-sandbox`'s
   * `middleware.ts`) alongside `sandboxKey`, when a disconnect leaves the
   * agent running rather than tearing the sandbox down. A backend must
   * round-trip this field: `listReclaimable` depends on it, and
   * `@tanstack/ai-sandbox`'s `reapDetachedRuns` sweeps the candidates it
   * surfaces (see that method's doc comment).
   */
  detachedSince?: number
  /**
   * Set by an explicit out-of-band cancel, to be distinguished from a mere
   * client disconnect (the two produce an identical TCP close, so intent is not
   * inferable from the disconnect).
   *
   * Written by `requestRunCancel` and read by `wasCancelRequested` (both in
   * `../cancel`). Deliberately NOT a status: recording intent is not the same as
   * the run having stopped, and only the driver knows when it has.
   */
  cancelRequested?: boolean
  /**
   * Monotonic fencing token for the run's driver. Bumped by each host that
   * successfully claims the run (see `withRunClaim` in `@tanstack/ai-sandbox`),
   * so a superseded host can discover it lost by comparing the stored value
   * against the one it holds.
   *
   * A lock alone cannot provide this: it tells the winner it won, but gives a
   * loser nothing to read. Absent on a run that was never claimed.
   */
  driverEpoch?: number
}

/**
 * Durable store for run lifecycle records.
 *
 * REQUIRED: `createOrResume`, `update`, `get`, `findActiveRun`. Every backend
 * must implement all four — they are what the persistence middleware calls
 * unconditionally. `findActiveRun` is required rather than feature-detected
 * because a backend that has not implemented it is indistinguishable from one
 * whose answer is legitimately `null`, so reconnect would silently do nothing
 * instead of failing at build time. It was optional for exactly one release
 * cycle and cost precisely that.
 *
 * OPTIONAL: `listByThread`, `listReclaimable`. Each serves one higher-level
 * feature (thread history, reclaim reaping) and callers feature-detect them,
 * degrading gracefully when a backend omits them.
 */
export interface RunStore {
  /**
   * Create a run record, or return the existing one unchanged if `runId` is
   * already present.
   *
   * INVARIANT (idempotency): an existing record is returned **unchanged** and
   * the passed `threadId`/`startedAt`/`status` are ignored. This is what makes
   * resuming a run safe. `status` defaults to `'running'` on first creation.
   */
  createOrResume: (
    input: Pick<RunRecord, 'runId' | 'threadId' | 'startedAt'> & {
      status?: RunStatus
    },
  ) => Promise<RunRecord>
  /**
   * Patch a record's mutable fields.
   *
   * INVARIANT: updating an unknown `runId` is a **no-op** — it must not throw
   * and must not create a record.
   */
  update: (
    runId: string,
    patch: Partial<
      Pick<
        RunRecord,
        | 'status'
        | 'finishedAt'
        | 'error'
        | 'usage'
        | 'sandboxKey'
        | 'detachedSince'
        | 'cancelRequested'
        | 'driverEpoch'
      >
    >,
  ) => Promise<void>
  /** Current record, or null when unknown. */
  get: (runId: string) => Promise<RunRecord | null>
  /**
   * Every run in a conversation, ascending by `startedAt`. OPTIONAL: only
   * needed to render a thread's past agent activity. Consumers feature-detect.
   */
  listByThread?: (threadId: string) => Promise<Array<RunRecord>>
  /**
   * Runs that may be reclaimed: ALL THREE of `status === 'running'`,
   * `detachedSince` is set, and `detachedSince <= now - ttlMs`. The cutoff is
   * **inclusive** — a run detached at exactly `now - ttlMs` IS reclaimable.
   *
   * OPTIONAL: only needed by a reaper. Consumers feature-detect.
   *
   * `detachedSince` is populated by `withSandbox`'s detach path (see
   * {@link RunRecord.detachedSince}). The sweep over the candidates this
   * surfaces is `@tanstack/ai-sandbox`'s `reapDetachedRuns`: it finalizes a run
   * whose agent already finished, expires one past its TTL, and reclaims the
   * sandbox. That is a function, not a scheduler — the application invokes it
   * (cron, queue, `alarm()`, `waitUntil`) — and a backend that omits this
   * method cannot be reaped at all.
   */
  listReclaimable?: (opts: {
    now: number
    ttlMs: number
  }) => Promise<Array<RunRecord>>
  /**
   * The most recent `'running'` run for `threadId`, or `null` if none is active.
   *
   * REQUIRED. This resolves "does this thread have a live run to attach to?"
   * from the STABLE thread id, which is the durable basis for reconnecting a
   * client (a reload, or the same thread opened on another device) — independent
   * of the ephemeral run id, which a single turn may mint several of. When more
   * than one run is `'running'`, the one with the greatest `startedAt` wins.
   *
   * A backend that stubs this to `null` turns reconnect off silently, because
   * `null` is also the correct answer for an idle thread. A backend with no run
   * lifecycle at all should omit the whole `runs` store instead — capability
   * tiers belong at the store level, not the method level.
   */
  findActiveRun: (threadId: string) => Promise<RunRecord | null>
}

/**
 * Type a {@link RunStore} implementation inline: pass the object and get
 * autocomplete plus contract checking with no separate annotation. Mirrors
 * `defineLock` / `defineSandboxInstanceStore`.
 *
 * The generic return preserves the argument's own type, so an optional method
 * the implementation actually provides stays known-present on the result
 * instead of collapsing back to `| undefined` on the interface.
 */
export function defineRunStore<const T extends RunStore>(store: T): T {
  return store
}

/**
 * Whether the current run can be DETACHED rather than destroyed when its client
 * disconnects — `true` only when some middleware has both a {@link RunStore} and
 * a durable event log wired (`withSandbox`'s `runs` + `durability.adapter`).
 *
 * Lives in core for the same reason `LockStore` does: it is a coordination fact
 * that two consumer packages must agree on, and neither may depend on the other.
 * `@tanstack/ai-sandbox` provides it; `@tanstack/ai-persistence` reads it to
 * decide whether an abort is terminal (`'aborted'`) or a detach (write nothing).
 * A persistence → sandbox import would be a layering inversion.
 *
 * Consumers read it with `{ optional: true }`: absent means "not detachable",
 * which is every app that has not wired durability.
 *
 * Typed `true`, not `boolean`: ABSENCE is the negative, so a published `false`
 * has no meaning — and a consumer that tests PRESENCE rather than the value
 * would read one as "detachable". Narrowing the payload makes that
 * unrepresentable instead of merely undocumented.
 */
export const DetachableRunCapability =
  createCapability<true>()('detachable-run')

/**
 * Destructured accessors: `getDetachableRun(ctx, { optional: true })` /
 * `provideDetachableRun(ctx, true)`.
 */
export const [getDetachableRun, provideDetachableRun] = DetachableRunCapability

/**
 * Whether this run's teardown DID detach — the disconnect was survived, the
 * agent is still working, and a later attach can take the run over.
 *
 * The past-tense counterpart of {@link DetachableRunCapability}, and the two must
 * not be confused:
 *
 * - **detachABLE** is published at `setup`, and only says a disconnect *may* be
 *   survived (a `RunStore` and a durable log are wired).
 * - **detachED** is published on the ABORT path, by the middleware that actually
 *   makes the call — `withSandbox`'s `onAbort`, which is the only actor that has
 *   resolved BOTH out-of-band cancel bands (`AbortInfo.cancelRequested` and
 *   `wasCancelRequested` on the record) and `detachOnDisconnect`. An explicit
 *   cancel, a non-detachable disconnect, an error, and a normal finish all leave
 *   it unpublished.
 *
 * Its consumer is the durable DELIVERY sink in `stream-to-response.ts`: a
 * detached run's log must stay OPEN and un-terminalized so the takeover can
 * continue it (see `wasRunDetached` in `../../../delivery-detach`). Reading it
 * is safe and race-free only because a `for await` over the chat stream awaits
 * the generator's `return()` — and therefore the whole `onAbort` chain — before
 * the sink's own `finally` runs.
 *
 * Read with `{ optional: true }`: absent means "not detached", which is every
 * other exit path and every app that has not wired durability.
 *
 * Typed `true`, not `boolean`, for the same reason as
 * {@link DetachableRunCapability}: absence is the only negative, so publishing
 * `false` must not be representable.
 */
export const RunDetachedCapability = createCapability<true>()('run-detached')

/**
 * Destructured accessors: `getRunDetached(ctx, { optional: true })` /
 * `provideRunDetached(ctx, true)`.
 */
export const [getRunDetached, provideRunDetached] = RunDetachedCapability

/** In-memory {@link RunStore}. Single process only. */
export class InMemoryRunStore implements RunStore {
  private readonly runs = new Map<string, RunRecord>()

  createOrResume(
    input: Pick<RunRecord, 'runId' | 'threadId' | 'startedAt'> & {
      status?: RunStatus
    },
  ): Promise<RunRecord> {
    const existing = this.runs.get(input.runId)
    if (existing) return Promise.resolve(existing)
    const record: RunRecord = {
      runId: input.runId,
      threadId: input.threadId,
      status: input.status ?? 'running',
      startedAt: input.startedAt,
    }
    this.runs.set(record.runId, record)
    return Promise.resolve(record)
  }

  update(
    runId: string,
    patch: Partial<
      Pick<
        RunRecord,
        | 'status'
        | 'finishedAt'
        | 'error'
        | 'usage'
        | 'sandboxKey'
        | 'detachedSince'
        | 'cancelRequested'
        | 'driverEpoch'
      >
    >,
  ): Promise<void> {
    const existing = this.runs.get(runId)
    if (existing) this.runs.set(runId, { ...existing, ...patch })
    return Promise.resolve()
  }

  get(runId: string): Promise<RunRecord | null> {
    return Promise.resolve(this.runs.get(runId) ?? null)
  }

  listByThread(threadId: string): Promise<Array<RunRecord>> {
    const matching = [...this.runs.values()]
      .filter((run) => run.threadId === threadId)
      .sort((a, b) => a.startedAt - b.startedAt)
    return Promise.resolve(matching)
  }

  listReclaimable(opts: {
    now: number
    ttlMs: number
  }): Promise<Array<RunRecord>> {
    const cutoff = opts.now - opts.ttlMs
    const matching = [...this.runs.values()].filter(
      (run) =>
        run.status === 'running' &&
        run.detachedSince !== undefined &&
        run.detachedSince <= cutoff,
    )
    return Promise.resolve(matching)
  }

  findActiveRun(threadId: string): Promise<RunRecord | null> {
    let active: RunRecord | null = null
    for (const run of this.runs.values()) {
      if (run.threadId !== threadId || run.status !== 'running') continue
      if (active === null || run.startedAt > active.startedAt) active = run
    }
    return Promise.resolve(active)
  }
}
