import type { TimelineItem } from "./streaming-v2";
import type { InputOutcome, WorkItemDiagnostic } from "./protocol-v2";

export type InputItem = Extract<TimelineItem, { lane: "input" }>;
export type CellItem = Extract<TimelineItem, { lane: "cell" }>;
export type SpeechItem = Extract<TimelineItem, { lane: "speech" }>;
export type LifecycleItem = Extract<TimelineItem, { lane: "lifecycle" }>;

/** Consecutive cell executions collapse into one row: twenty identical cells is not information. */
export interface CellRun {
    kind: "cells";
    id: string;
    items: CellItem[];
}

/**
 * The same rule for the bookkeeping lane. `model usage` repeated six times as six separate lines is
 * six rows of the same sentence; one row that says how many, and which kinds, is the same fact.
 */
export interface LifecycleRun {
    kind: "lifecycle";
    id: string;
    items: LifecycleItem[];
}

export type TurnChild = TimelineItem | CellRun | LifecycleRun;

export function isCellRun(child: TurnChild): child is CellRun {
    return (child as CellRun).kind === "cells";
}

export function isLifecycleRun(child: TurnChild): child is LifecycleRun {
    return (child as LifecycleRun).kind === "lifecycle";
}

export interface TurnSummary {
    cells: number;
    ok: number;
    failed: number;
    delivered: number;
    held: number;
    rejected: number;
    scratch: number;
    lifecycle: number;
}

export interface Turn {
    id: string;
    /** The human input that opened this turn, when there was one. */
    input?: InputItem;
    startedAt: number;
    /**
     * Where this turn sits in the session's own log: the sequence of its opening record. A console that
     * loads a window cannot count turns from one — that would number turn 361 as "#1" and tell the
     * reader the session began where their window does — so a turn is named by the coordinate that is
     * the same whatever part of the session is loaded.
     */
    seq?: number;
    children: TurnChild[];
    summary: TurnSummary;
}

/**
 * Which lanes the operator is looking at. A set, not a single choice: "conversation" is a named
 * preset rather than a mode, so you can watch your own words next to the machine's output without
 * being forced into one view.
 */
export type LaneSelection = string[];

export const LANE_IDS = ["speech", "cell", "scratchpad", "lifecycle", "input"] as const;

export function isSelected(selection: LaneSelection, id: string): boolean {
    return selection.includes(id);
}

/** Everything, expressed as a selection. */
export function everything(): LaneSelection {
    return ["conversation", ...LANE_IDS];
}

/**
 * The view a reader wants without choosing: what you said, everything the agent said whether or not
 * the delivery review let it through, and what it ran. `conversation` is redundant beside `speech`
 * (it only adds your own inputs) but it is what makes the inputs appear, so it stays.
 */
export const TRANSCRIPT_PRESET: LaneSelection = ["conversation", "speech", "cell"];

export function sameSelection(a: LaneSelection, b: LaneSelection): boolean {
    return a.length === b.length && a.every((id) => b.includes(id));
}

export function filterItems(items: TimelineItem[], selection: LaneSelection): TimelineItem[] {
    if (selection.length === 0) return [];
    const wants = new Set(selection);
    const all = wants.has("conversation") && LANE_IDS.every((id) => wants.has(id));
    if (all) return items;

    return items.filter((item) => {
        if (wants.has(item.lane)) return true;
        // the conversation preset: your own words, plus the speech that was actually delivered
        if (wants.has("conversation")) {
            if (item.lane === "input") return true;
            if (item.lane === "speech" && item.data.receipt?.status === "confirmed") return true;
        }
        return false;
    });
}

/** True when this record belongs to the conversation preset but not to a lane the operator picked. */
export function onlyViaConversation(item: TimelineItem, selection: LaneSelection): boolean {
    return selection.includes("conversation") && !selection.includes(item.lane);
}

function receiptOf(item: TimelineItem): string | undefined {
    return item.lane === "speech" ? item.data.receipt?.status : undefined;
}

function summarise(items: TimelineItem[]): TurnSummary {
    const summary: TurnSummary = { cells: 0, ok: 0, failed: 0, delivered: 0, held: 0, rejected: 0, scratch: 0, lifecycle: 0 };
    for (const item of items) {
        switch (item.lane) {
            case "cell": {
                summary.cells += 1;
                const status = item.data.status;
                if (status === "ok") summary.ok += 1;
                else if (status === "error" || item.data.exit_code) summary.failed += 1;
                break;
            }
            case "speech": {
                const receipt = receiptOf(item);
                if (receipt === "confirmed") summary.delivered += 1;
                else if (receipt === "held") summary.held += 1;
                else if (receipt === "rejected" || receipt === "failed") summary.rejected += 1;
                break;
            }
            case "scratchpad":
                summary.scratch += 1;
                break;
            case "lifecycle":
                summary.lifecycle += 1;
                break;
            default:
                break;
        }
    }
    return summary;
}

/** Collapse runs of consecutive cell executions, keeping every other record in order. */
/** Runs of like records collapse; a run of one is just the record. */
function collapseRuns(items: TimelineItem[]): TurnChild[] {
    const children: TurnChild[] = [];
    let cells: CellItem[] = [];
    let life: LifecycleItem[] = [];

    const flushCells = () => {
        if (cells.length === 0) return;
        if (cells.length === 1) children.push(cells[0]);
        else children.push({ kind: "cells", id: `run-${cells[0].id}`, items: [...cells] });
        cells = [];
    };
    const flushLife = () => {
        if (life.length === 0) return;
        if (life.length === 1) children.push(life[0]);
        else children.push({ kind: "lifecycle", id: `life-${life[0].id}`, items: [...life] });
        life = [];
    };

    for (const item of items) {
        if (item.lane === "cell") {
            flushLife();
            cells.push(item);
            continue;
        }
        if (item.lane === "lifecycle") {
            flushCells();
            life.push(item);
            continue;
        }
        flushCells();
        flushLife();
        children.push(item);
    }
    flushCells();
    flushLife();
    return children;
}

/**
 * What became of each thing the operator said. The answer for a turn is the evidence between it and the
 * next input — the delivered speech, the held speech, the failing cell — and one exact answer that
 * outranks all of it: a `turn.outcome` record naming the input's own event ids.
 *
 * The evidence is gathered in the single walk that finds the segments, not by scanning the transcript
 * once per input. It used to scan per input, and this runs on every timeline change: with a few thousand
 * records that scan was the largest single cost in the console, larger than the whole render.
 */
export function computeInputOutcomes(
    items: TimelineItem[],
    work: WorkItemDiagnostic[] = []
): Map<string, InputOutcome> {
    // First pass: the answers that are stated outright, keyed by the input sequence they name.
    const exactBySeq = new Map<number, InputOutcome>();
    for (const item of items) {
        if (item.lane !== "lifecycle") continue;
        const kind = (item.data as { kind?: string }).kind;
        if (kind !== "turn.outcome" && kind !== "turn.failed") continue;
        const data = (item.data as { data?: Record<string, unknown> }).data ?? {};
        const ids = Array.isArray(data.input_event_ids) ? (data.input_event_ids as number[]) : [];
        const status = String(data.status ?? (kind === "turn.failed" ? "failed" : ""));
        const reason = String(data.error ?? data.reason ?? (item.data as { reason?: string }).reason ?? "");
        const outcome: InputOutcome =
            kind === "turn.failed" || status === "failed"
                ? { status: "failed", reason: reason || "the turn failed" }
                : status === "held"
                ? { status: "held", reason: reason || "held by delivery review" }
                : status === "replied"
                ? { status: "replied" }
                : { status: "no_reply", reason: "the turn completed without speech" };
        for (const id of ids) exactBySeq.set(id, outcome);
    }

    // Second pass: walk the transcript, keeping the segment started by the open input. Each input is
    // decided when the next one closes it, so every record is read exactly once.
    const failedWork = work.filter((item) => item.status === "failed");
    const outcomes = new Map<string, InputOutcome>();
    let open: InputItem | null = null;
    let delivered = false;
    let heldReason: string | null = null;
    let failedCell: CellItem | null = null;
    let failedLifecycle = false;

    const close = (last: boolean) => {
        const input = open;
        if (!input) return;
        open = null;
        if (input.seq !== undefined && exactBySeq.has(input.seq)) {
            outcomes.set(input.id, exactBySeq.get(input.seq)!);
        } else if (delivered) {
            outcomes.set(input.id, { status: "replied" });
        } else if (heldReason !== null) {
            outcomes.set(input.id, { status: "held", reason: heldReason });
        } else {
            const failed = failedCell
                ? failedCell.data.outputs.find((output) => output.stream === "stderr")?.text
                    ?.split("\n")
                    .filter(Boolean)
                    .slice(-1)[0] || `the cell exited ${failedCell.data.exit_code ?? "non-zero"}`
                : failedWork.find((item) => input.seq !== undefined && item.id.includes(`input-${input.seq}`))?.title;
            if (failed || failedLifecycle) {
                outcomes.set(input.id, { status: "failed", reason: failed || "the turn failed" });
            } else if (last) {
                outcomes.set(input.id, { status: "running" });
            } else {
                outcomes.set(input.id, { status: "no_reply", reason: "the turn completed without speech" });
            }
        }
        delivered = false;
        heldReason = null;
        failedCell = null;
        failedLifecycle = false;
    };

    for (const item of items) {
        if (item.lane === "input") {
            close(false);
            open = item;
            continue;
        }
        if (!open) continue;
        switch (item.lane) {
            case "speech": {
                const receipt = item.data.receipt;
                if (receipt?.status === "confirmed") delivered = true;
                else if (receipt?.status === "held" && heldReason === null) {
                    heldReason = receipt.reason || "held by delivery review";
                }
                break;
            }
            case "cell":
                if (failedCell === null && (item.data.status === "error" || (item.data.exit_code ?? 0) !== 0)) {
                    failedCell = item;
                }
                break;
            case "lifecycle":
                if (String((item.data as { kind?: string }).kind) === "failed") failedLifecycle = true;
                break;
            default:
                break;
        }
    }
    close(true);
    return outcomes;
}

/**
 * A turn is one human input and everything the runtime did about it. Records that arrive before any
 * input (session activation, a model-driven start) form a leading turn of their own, so nothing is
 * dropped and nothing is invented.
 */
export function groupTurns(items: TimelineItem[]): Turn[] {
    const turns: Turn[] = [];
    let current: TimelineItem[] = [];
    let input: InputItem | undefined;

    const close = () => {
        if (current.length === 0 && !input) return;
        turns.push({
            id: input?.id ?? current[0]?.id ?? `turn-${turns.length}`,
            input,
            startedAt: input?.created_ms ?? current[0]?.created_ms ?? Date.now(),
            seq: input?.seq ?? current[0]?.seq,
            children: collapseRuns(current),
            summary: summarise(current),
        });
        current = [];
        input = undefined;
    };

    for (const item of items) {
        if (item.lane === "input") {
            close();
            input = item;
            continue;
        }
        current.push(item);
    }
    close();
    return turns;
}

/**
 * One row is one line of the transcript. The transcript is a tree — turn, then the records inside it,
 * then the cells inside an open run — and a virtualizer can only place a flat list. The tree is
 * flattened here, in the data layer, so the view receives rows that already know their own key, their
 * own spacing and their own timestamp. Doing it here also makes it testable without a browser.
 */
export type Row = {
    key: string;
    turnId: string;
    /** The moment this row reports to the horizon readout. */
    at: number;
    /** The last row of its turn: it carries the turn's own bottom gap. */
    last: boolean;
    /** A guess until the row has been measured. Only ever used for rows off screen. */
    estimate: number;
} & (
    /**
     * The top of the loaded window. A transcript is read from its live end, so the records above it are
     * on the daemon, not here: this row is what asks for them, and it is a row rather than a floating
     * control so it can never be mistaken for a record of the session.
     */
    | { kind: "history" }
    /**
     * `partial` marks the one turn a window can cut in half: the first, when the records that opened it
     * are above the window. Its counts are counts of the records that are loaded, which is not the same
     * claim as a turn's summary, so it makes none.
     */
    | { kind: "head"; turn: Turn; no: number; partial?: boolean }
    | { kind: "input"; turn: Turn; item: InputItem }
    | { kind: "child"; turn: Turn; child: TurnChild }
    | { kind: "cell"; turn: Turn; cell: CellItem; endOfRun: boolean }
);

const ESTIMATE = { head: 44, input: 56, cell: 96, record: 52 } as const;

/** A collapsed run is one line; a lone cell is a terminal; everything else is a short record. */
function estimateOf(child: TurnChild): number {
    if (isCellRun(child) || isLifecycleRun(child)) return ESTIMATE.record;
    return child.lane === "cell" ? ESTIMATE.cell : ESTIMATE.record;
}

function atOf(child: TurnChild): number {
    if (isCellRun(child) || isLifecycleRun(child)) return child.items[0]?.created_ms ?? 0;
    return child.created_ms;
}

/**
 * `open` is the set of expanded cell runs. An expanded run emits one row per cell, which is the whole
 * point of building rows here: a turn that ran two hundred cells is two hundred rows the scroller can
 * drop, not one enormous element it must keep.
 */
export function buildRows(turns: Turn[], open: Record<string, boolean> = {}, earlier = false): Row[] {
    const rows: Row[] = [];
    // Only the top of the loaded window can cut a turn: every page below it joins exactly where the
    // page above it ended, so a turn split by one page is whole again once the next one arrives.
    const cut = earlier && turns.length > 0 && !turns[0].input;
    if (earlier) {
        // `at: 0` keeps it out of the horizon readout, which reports the moment the reader is looking at.
        rows.push({ kind: "history", key: "history", turnId: "", at: 0, last: false, estimate: 40 });
    }
    for (const [i, turn] of turns.entries()) {
        // A record with no sequence is synthetic (the tests build them): rank is all it has.
        rows.push({
            kind: "head", key: `${turn.id}:head`, turnId: turn.id, at: turn.startedAt, last: false,
            estimate: ESTIMATE.head, turn, no: turn.seq ?? i + 1, partial: cut && i === 0,
        });
        if (turn.input) {
            rows.push({ kind: "input", key: turn.input.id, turnId: turn.id, at: turn.input.created_ms, last: false, estimate: ESTIMATE.input, turn, item: turn.input });
        }
        for (const child of turn.children) {
            const at = atOf(child);
            if (isCellRun(child) && open[child.id]) {
                rows.push({ kind: "child", key: `${child.id}:head`, turnId: turn.id, at, last: false, estimate: ESTIMATE.record, turn, child });
                for (const [j, cell] of child.items.entries()) {
                    rows.push({ kind: "cell", key: cell.id, turnId: turn.id, at: cell.created_ms, last: false, estimate: ESTIMATE.cell, turn, cell, endOfRun: j === child.items.length - 1 });
                }
                continue;
            }
            rows.push({ kind: "child", key: child.id, turnId: turn.id, at, last: false, estimate: estimateOf(child), turn, child });
        }
    }
    // spacing belongs to the boundary between turns, so both rows that touch it need to know
    for (let i = 1; i < rows.length; i++) {
        if (rows[i].turnId !== rows[i - 1].turnId) rows[i - 1].last = true;
    }
    if (rows.length > 0) rows[rows.length - 1].last = true;
    return rows;
}
