/**
 * remote/port — the RemoteControlPort + the RemoteControlHub reference impl.
 *
 * The hub is the central object of remote-control. It:
 *   - IS a `SessionEventSink` — the agent loop emits events to it;
 *   - keeps a ring buffer of recent events so a reconnecting client can
 *     backfill gap-free (using each event's monotonic `seq`);
 *   - fans events out to live subscribers (the SSE/relay transports);
 *   - owns the `InstructionInbox` — the inbound steering channel;
 *   - derives live `SessionState` from the event stream.
 *
 * Transport-agnostic: `http-server.ts` and `relay-client.ts` are thin
 * adapters over this hub — exactly the port/transport split the SDK already
 * uses for HITL (`ApprovalChannel` + `hitl/slack.ts`).
 *
 * @public @since 2.11.0 — preste remote-control T1
 */

import { type EventNamingMode, normalizeEventType } from "./event-name-map.js";
import type { SessionEvent } from "./events.js";
import { InMemoryInstructionInbox, type InstructionInbox } from "./inbox.js";
import { type PersistencePort, tryPersistEvent } from "./persistence.js";
import { type SignFn, signEvent } from "./signing.js";
import type { SessionEventSink } from "./sink.js";
import { InMemoryVetoChannel, type VetoChannel } from "./veto.js";

/** A live snapshot of the controlled session. */
export interface SessionState {
  runId: string | null;
  agentId: string | null;
  status: "idle" | "running" | "finished";
  stepCount: number;
  costUsd: number;
  elapsedMs: number;
  /** HITL requests currently awaiting a verdict. */
  pendingHitl: number;
}

/**
 * The surface a remote client drives. The hub is the reference impl; any
 * transport (HTTP/SSE, relay, a future gRPC adapter) consumes this port.
 */
export interface RemoteControlPort extends SessionEventSink {
  /** Subscribe to live events. Returns an unsubscribe function. */
  subscribe(fn: (event: SessionEvent) => void): () => void;
  /**
   * Replay buffered events with `seq` strictly greater than `sinceSeq`
   * (omit `sinceSeq` for the full buffer) — gap-free reconnection.
   */
  backlog(sinceSeq?: number): SessionEvent[];
  /** The inbound steering channel. */
  readonly inbox: InstructionInbox;
  /** Pre-tool-call veto bus — T6a veto window. */
  readonly vetoChannel: VetoChannel;
  /** Current derived session state. */
  state(): SessionState;
}

export interface RemoteControlHubOptions {
  /** Max events retained for backfill. Default 1000. */
  bufferSize?: number;
  /** Inbox implementation. Default: a fresh InMemoryInstructionInbox. */
  inbox?: InstructionInbox;
  /**
   * When provided, every emitted event is Ed25519-signed before it is
   * buffered + streamed — the remote-control transcript becomes a
   * tamper-evident audit trail. See `remote/signing.ts`.
   */
  signFn?: SignFn;
  /** Pre-tool-call veto bus (T6a). Default: a fresh InMemoryVetoChannel. */
  vetoChannel?: VetoChannel;
  /**
   * Durable backing store for events + revocations (T7). When provided,
   * every emitted event is fire-and-forget written to disk. Persistence
   * failures are logged but never thrown into the agent loop.
   *
   * The hub does NOT pre-load events from persistence — callers wanting
   * to warm the buffer after a crash should call
   * `await loadHubFromPersistence(hub, persistence, opts)` before
   * starting the agent run.
   */
  persistence?: PersistencePort;
  /**
   * Event vocabulary used for the emitted `type` field.
   *
   * - `"canonical"` (default since 2.28.0) ; AG-UI Linux Foundation canonical
   *   UPPER_SNAKE_CASE (`RUN_STARTED`, `TEXT_MESSAGE_CONTENT`, etc.) ; aligned
   *   with the Linux Foundation AG-UI conformance suite.
   * - `"legacy"` (opt-in since 2.28.0) ; Vauban dotted-lowercase (`run.start`,
   *   etc.) ; required by consumers that have not yet shipped a canonical-aware
   *   reader (preste-pwa, dashboard wire-format, gateway render, signed-event
   *   verification flows that pre-existed 2.28.0). Pass this explicitly until
   *   every downstream is canonical-aware.
   *
   * Vauban-specific extensions with no AG-UI peer (`tool.intent`, `hitl.*`,
   * `instruction.injected`) are projected onto `CUSTOM_*` names in canonical
   * mode ; round-tripping through `event-name-map.ts` is lossless.
   *
   * Roadmap : 2.29.0 emits a deprecation warning on every legacy-mode emit ;
   * 3.0.0 removes legacy entirely. See {@link normalizeEventType} +
   * `event-name-map.ts`.
   *
   * @default "canonical"
   * @public @since 2.27.0 (default flipped to canonical in 2.28.0)
   */
  eventNaming?: EventNamingMode;
}

/** Create a RemoteControlHub — the reference RemoteControlPort. */
export function createRemoteControlHub(
  opts: RemoteControlHubOptions = {}
): RemoteControlPort {
  const bufferSize = opts.bufferSize ?? 1000;
  const inbox: InstructionInbox = opts.inbox ?? new InMemoryInstructionInbox();
  const vetoChannel: VetoChannel =
    opts.vetoChannel ?? new InMemoryVetoChannel();
  const eventNaming: EventNamingMode = opts.eventNaming ?? "canonical";

  const buffer: SessionEvent[] = [];
  const subscribers = new Set<(e: SessionEvent) => void>();

  const state: SessionState = {
    runId: null,
    agentId: null,
    status: "idle",
    stepCount: 0,
    costUsd: 0,
    elapsedMs: 0,
    pendingHitl: 0,
  };
  let startedAtMs: number | null = null;

  /**
   * Fold one event into the derived session state. The folder operates on
   * the LEGACY type vocabulary ; if the incoming event arrived in canonical
   * form, we project its type to legacy for the switch — payload shapes
   * are vocabulary-agnostic so this is safe.
   */
  function updateState(event: SessionEvent): void {
    const legacyType = normalizeEventType(event.type, "legacy");
    switch (legacyType) {
      case "run.start":
        state.runId = (event.data as { runId: string }).runId;
        state.agentId = (event.data as { agentId: string }).agentId;
        state.status = "running";
        state.stepCount = 0;
        state.costUsd = 0;
        startedAtMs = Date.now();
        break;
      case "run.step":
        state.stepCount = (event.data as { stepIndex: number }).stepIndex + 1;
        state.costUsd += (event.data as { costUsd: number }).costUsd;
        break;
      case "run.finished":
        state.status = "finished";
        state.stepCount = (event.data as { stepCount: number }).stepCount;
        state.costUsd = (event.data as { costUsd: number }).costUsd;
        break;
      case "hitl.request":
        state.pendingHitl += 1;
        break;
      case "hitl.resolved":
        state.pendingHitl = Math.max(0, state.pendingHitl - 1);
        break;
      default:
        break;
    }
  }

  return {
    emit(rawEvent: SessionEvent): void {
      // Sign first — the buffered + streamed event must carry `sig` so a
      // reconnecting client and a live client see the same signed event.
      let event = rawEvent;
      if (opts.signFn) {
        try {
          event = signEvent(rawEvent, opts.signFn);
        } catch {
          event = rawEvent; // signing failure is non-fatal — emit unsigned
        }
      }
      // Normalise the `type` field to the configured naming mode at the
      // emit boundary. Legacy mode is the default ; canonical opt-in
      // rewrites the type to AG-UI UPPER_SNAKE_CASE (no double-emission,
      // one logical event = one buffered + streamed frame).
      //
      // The signature is computed BEFORE the type rewrite so signed payloads
      // remain canonical-content-stable through the rename. A verifier that
      // accepts both forms (per `looksLikeSessionEvent`) will validate the
      // signature regardless of the surface type vocabulary.
      const normalizedType = normalizeEventType(event.type, eventNaming);
      if (normalizedType !== event.type) {
        event = { ...event, type: normalizedType } as SessionEvent;
      }
      updateState(event);
      buffer.push(event);
      if (buffer.length > bufferSize) buffer.shift();
      // Fire-and-forget persistence write. Failures are logged inside
      // tryPersistEvent ; never throw into the agent loop.
      void tryPersistEvent(opts.persistence, event);
      for (const fn of subscribers) {
        try {
          fn(event);
        } catch {
          // a broken subscriber must never break the run
          subscribers.delete(fn);
        }
      }
    },

    subscribe(fn: (event: SessionEvent) => void): () => void {
      subscribers.add(fn);
      return () => subscribers.delete(fn);
    },

    backlog(sinceSeq?: number): SessionEvent[] {
      if (sinceSeq === undefined) return [...buffer];
      return buffer.filter((e) => e.seq > sinceSeq);
    },

    inbox,

    vetoChannel,

    state(): SessionState {
      return {
        ...state,
        elapsedMs: startedAtMs === null ? 0 : Date.now() - startedAtMs,
      };
    },
  };
}

/**
 * Warm a hub's in-memory backlog from a persistence store. Call once
 * after `createRemoteControlHub` and before serving traffic ; the events
 * become accessible via `hub.backlog()` exactly as if they had just been
 * emitted (no re-signing, no subscriber dispatch).
 *
 * Bounded by the hub's `bufferSize` ; oldest events are dropped first.
 *
 * @param hub          The hub returned by {@link createRemoteControlHub}.
 * @param persistence  The same persistence handed to the hub options.
 * @param opts         Optional cutoff `sinceSeq` (load only newer events).
 *
 * @public @since 2.21.0 — preste remote-control P7
 */
export async function loadHubFromPersistence(
  hub: RemoteControlPort,
  persistence: PersistencePort,
  opts: { sinceSeq?: number } = {}
): Promise<{ loaded: number; maxSeq: number }> {
  const events = await persistence.loadEvents(opts.sinceSeq);
  const maxSeq = await persistence.getMaxSeq();
  // The port doesn't expose `buffer.push` directly — we go through emit().
  // To avoid double-persistence (re-writing every loaded event), the port
  // skips `tryPersistEvent` when no persistence is configured. For replay,
  // we therefore use a side-door: subscribe-replay-unsubscribe.
  //
  // The simpler path is to use the public `emit` and accept idempotent
  // re-writes — `InMemoryPersistencePort.saveEvent` is idempotent on
  // `(seq, id)` and SQLite uses `INSERT OR IGNORE`. The cost is one extra
  // round-trip per event but reload is a startup-time operation.
  for (const event of events) {
    hub.emit(event);
  }
  return { loaded: events.length, maxSeq };
}
