// What's-New strip + detail widget.
//
// Startup behavior (post-FORGE-BUG-001 refactor): on session_start we emit
// a one-line passive notify pointing the user at `/changelog` — we do NOT
// auto-mount the interactive strip and we do NOT hook the input router.
// The strip used to live below the editor for the entire session and steal
// every ↓ key; users hated it.
//
// The interactive strip is now ONLY mounted in response to `/changelog`.
// UX once mounted:
//
//   INACTIVE (default): "what's new ─ pi 30 · forge-plugin 8 · forge-cli 2  ↓ to view"
//   ACTIVE   (↓):       "what's new ─ ▸● pi 30   ○ forge-plugin 8   ○ forge-cli 2  ←→ · enter · esc hide · d forget"
//
//   Enter on a chip → setOutputSource(WhatsNewDetailComponent).
//   Esc → soft hide for this session (unmounts widget, drops input handler;
//         /changelog cache is untouched, so /changelog still replays).
//   d   → permanent forget (collapses prev baseline so /changelog returns
//         "no recent updates" until the next version bump).
//
// Activation key (↓) requires an empty editor (no text at all), not just
// "no newlines" — single-line text used to be enough and that was too
// eager.
//
// Mount is idempotent: a second /changelog while the strip is already
// mounted unregisters the prior input-router handler before re-registering,
// so handlers don't stack.
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { getInputRouter } from "../tui/input-router.js";
import { getUserCacheDir } from "../paths/paths.js";
import { computeAndPersistStartupPanel, computeSummaries, computeWhatsNewView, dismissWhatsNew, readSeenState, renderComponentDetail, renderSummaryPanel, resolveChangelogPaths, } from "./whats-new.js";
const WIDGET_KEY = "forge:whats-new";
// ── Detail-view component: rendered into chat viewport on Enter ──────────
class WhatsNewDetailComponent {
    lines;
    invalidationCb;
    constructor(lines) {
        this.lines = lines;
    }
    render(width) {
        return this.lines.map((l) => (visibleWidth(l) <= width ? l : truncateToWidth(l, width)));
    }
    invalidate() {
        // Static content — re-render only when caller updates `lines`.
    }
    setInvalidationCallback(cb) {
        this.invalidationCb = cb;
    }
    updateLines(lines) {
        this.lines = lines;
        this.invalidationCb?.();
    }
    dispose = () => { };
}
// ── Strip component: single row below the editor ─────────────────────────
class WhatsNewStripComponent {
    summaries;
    theme;
    cursorIdx = 0;
    stripActive = false;
    /** Component currently focused into the chat viewport. */
    focusedComponent = null;
    invalidationCb;
    constructor(summaries, theme) {
        this.summaries = summaries;
        this.theme = theme;
    }
    setInvalidationCallback(cb) {
        this.invalidationCb = cb;
    }
    invalidate() {
        // Re-render driven by caller via invalidationCb → tui.requestRender().
    }
    hasContent() {
        return this.summaries.length > 0;
    }
    getStripActive() {
        return this.stripActive;
    }
    cursorSummary() {
        return this.summaries[this.cursorIdx];
    }
    setStripActive(active) {
        if (this.stripActive === active)
            return;
        this.stripActive = active;
        this.invalidationCb?.();
    }
    moveCursor(delta) {
        if (this.summaries.length === 0)
            return;
        this.cursorIdx = Math.max(0, Math.min(this.summaries.length - 1, this.cursorIdx + delta));
        this.invalidationCb?.();
    }
    setFocused(id) {
        this.focusedComponent = id;
        this.invalidationCb?.();
    }
    clearSummaries() {
        this.summaries = [];
        this.focusedComponent = null;
        this.invalidationCb?.();
    }
    render(width) {
        if (!this.hasContent())
            return [];
        const dim = (s) => this.theme.fg("dim", s);
        const accent = (s) => this.theme.fg("accent", s);
        const bold = (s) => this.theme.bold(s);
        const prefix = this.stripActive ? accent("what's new ─ ") : dim("what's new ─ ");
        const hint = this.stripActive ? dim("  ←→ · enter · esc hide · d forget") : dim("  ↓ to view (empty editor)");
        const parts = this.summaries.map((s, i) => {
            const focusedGlyph = this.focusedComponent === s.component ? "●" : "○";
            const inner = `${focusedGlyph} ${s.label} ${s.totalChanges}`;
            if (this.stripActive && i === this.cursorIdx)
                return accent(bold(`▸${inner}`));
            return dim(inner);
        });
        const chips = parts.join("   ");
        let line = `${prefix}${chips}${hint}`;
        if (visibleWidth(line) > width)
            line = truncateToWidth(line, width);
        return [line];
    }
    dispose = () => { };
}
// ── Key recognition (mirrors thread-switcher.ts) ─────────────────────────
function isLeftArrow(d) {
    return d === "\x1b[D";
}
function isRightArrow(d) {
    return d === "\x1b[C";
}
function isDownArrow(d) {
    return d === "\x1b[B" || d === "\x1bOB";
}
function isUpArrow(d) {
    return d === "\x1b[A" || d === "\x1bOA";
}
function isEnter(d) {
    return d === "\r" || d === "\n";
}
function isEsc(d) {
    return d === "\x1b";
}
// ── Mount ────────────────────────────────────────────────────────────────
/**
 * Compute startup summaries and, if any, emit a one-line passive notify
 * pointing the user at `/changelog`. Marks versions as seen via the
 * standard cache so subsequent sessions won't re-notify unless a new bump
 * occurs. Returns true when a notify was emitted.
 *
 * Pre-FORGE-BUG-001 this mounted the interactive strip directly; that was
 * removed because the strip captured the ↓ key for the whole session and
 * the auto-mount happened before the user had any chance to opt in.
 */
export async function mountWhatsNewWidgetOnStartup(_pi, ctx, rt) {
    const result = await computeAndPersistStartupPanel(rt);
    if (!result || result.summaries.length === 0)
        return false;
    const parts = result.summaries.map((s) => `${s.label} ${s.totalChanges}`).join(" · ");
    try {
        ctx.ui.notify(`forge: what's new — ${parts}.  Type /changelog to view.`, "info");
    }
    catch {
        /* stale ctx — non-fatal */
    }
    return true;
}
/**
 * Module-level mount state — used to make `mountStripWithSummaries`
 * idempotent. Calling /changelog twice used to stack input-router
 * handlers; we now tear down the previous mount first.
 */
let currentUnregister = null;
/**
 * Mount the strip with a pre-computed summary list. Idempotent — a second
 * call unregisters the prior handler and replaces the widget surface.
 * Only `/changelog` calls this; startup notify does not.
 */
export function mountStripWithSummaries(_pi, ctx, rt, summaries) {
    let stripRef;
    let tuiRef;
    let detailRef;
    // FORGE-BUG-001: the captured ExtensionContext goes stale after pi's
    // session replacement events. Touching ctx.ui after that throws.
    const safeUi = (fn) => {
        try {
            return fn();
        }
        catch {
            return undefined;
        }
    };
    // Idempotency: drop any prior input-router handler before re-registering.
    if (currentUnregister) {
        try {
            currentUnregister();
        }
        catch {
            /* ignore */
        }
        currentUnregister = null;
    }
    try {
        ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
            tuiRef = tui;
            const strip = new WhatsNewStripComponent(summaries, theme);
            strip.setInvalidationCallback(() => tui.requestRender());
            stripRef = strip;
            return strip;
        }, { placement: "belowEditor" });
    }
    catch (err) {
        if (process.env.FORGE_DEBUG_WHATS_NEW === "1") {
            console.error("[forge-cli whats-new] setWidget failed:", err);
        }
        return;
    }
    const setFocusToMain = () => {
        stripRef?.setFocused(null);
        detailRef?.dispose?.();
        detailRef = undefined;
        safeUi(() => ctx.ui.setOutputSource(null));
    };
    const commitFocus = () => {
        if (!stripRef)
            return;
        const summary = stripRef.cursorSummary();
        if (!summary)
            return;
        stripRef.setFocused(summary.component);
        const lines = renderComponentDetail(summary).split("\n");
        if (detailRef) {
            detailRef.updateLines(lines);
        }
        else {
            detailRef = new WhatsNewDetailComponent(lines);
            if (tuiRef)
                detailRef.setInvalidationCallback(() => tuiRef?.requestRender());
        }
        if (detailRef)
            safeUi(() => ctx.ui.setOutputSource(detailRef));
    };
    // Tear down the entire mount — used by Esc (hide for this session) and
    // by `d` (permanent forget). Both unmount the widget surface and drop
    // the input-router handler so ↓ stops being captured. The difference is
    // whether dismissWhatsNew() is invoked to collapse the prev baseline.
    const tearDown = () => {
        setFocusToMain();
        safeUi(() => ctx.ui.setWidget(WIDGET_KEY, undefined));
        stripRef?.clearSummaries();
        stripRef = undefined;
        if (currentUnregister) {
            try {
                currentUnregister();
            }
            catch {
                /* ignore */
            }
            currentUnregister = null;
        }
    };
    const unregister = getInputRouter().register((data) => {
        if (!stripRef || !stripRef.hasContent())
            return undefined;
        if (!stripRef.getStripActive()) {
            if (!isDownArrow(data))
                return undefined;
            const editorText = safeUi(() => ctx.ui.getEditorText());
            if (editorText === undefined)
                return undefined; // stale ctx
            // Stricter than the legacy "no newlines" check — only an
            // empty editor activates the strip. Any in-progress text
            // (single-line included) means ↓ belongs to the editor.
            if (editorText !== "")
                return undefined;
            stripRef.setStripActive(true);
            return { consume: true };
        }
        if (isLeftArrow(data)) {
            stripRef.moveCursor(-1);
            return { consume: true };
        }
        if (isRightArrow(data)) {
            stripRef.moveCursor(1);
            return { consume: true };
        }
        if (isUpArrow(data)) {
            stripRef.setStripActive(false);
            return { consume: true };
        }
        if (isEnter(data)) {
            commitFocus();
            return { consume: true };
        }
        if (isEsc(data)) {
            // Soft hide for this session — strip disappears, ↓ is
            // released, but the cache is untouched so /changelog can
            // re-summon the same set.
            tearDown();
            return { consume: true };
        }
        if (isForgetKey(data)) {
            // Permanent forget — collapse prev baseline so /changelog
            // returns "no recent updates" until the next version bump.
            void dismissWhatsNew(rt).catch(() => undefined);
            tearDown();
            return { consume: true };
        }
        return undefined;
    }, { name: "whats-new-strip", skipWhenOverlayActive: true });
    currentUnregister = unregister;
}
function isForgetKey(d) {
    return d === "d" || d === "D";
}
/**
 * Compute the summaries that should populate the strip when /whats-new is
 * invoked: prev baseline first (post auto-dismiss), then lastShown frozen
 * baseline (post explicit dismiss). Returns empty when neither yields a
 * set — caller falls back to a notify message.
 */
async function computeReplaySummaries(rt) {
    const cacheDir = rt.cacheDir ?? getUserCacheDir();
    const seen = await readSeenState(cacheDir);
    const sources = resolveChangelogPaths(rt.pkgRoot);
    let s = computeSummaries({ sources, current: rt.current, seen, baseline: "prev" });
    if (s.length === 0)
        s = computeSummaries({ sources, current: rt.current, seen, baseline: "lastShown" });
    return s;
}
/**
 * Register the /changelog slash command (overrides pi's built-in): re-mounts
 * the interactive strip widget covering pi + forge-plugin + forge-cli changelogs.
 * `/changelog dismiss` clears the replay baseline. Headless / non-TTY callers
 * fall through to a text notify so the command is still usable.
 */
export function registerChangelogCommand(pi, rt) {
    pi.registerCommand("changelog", {
        description: "Show What's New across pi, forge-plugin, and forge-cli (or /changelog dismiss to clear)",
        async handler(args, ctx) {
            const arg = args.trim().toLowerCase();
            try {
                if (arg === "dismiss" || arg === "--dismiss") {
                    await dismissWhatsNew(rt);
                    ctx.ui.notify("changelog: dismissed.", "info");
                    return;
                }
                const summaries = await computeReplaySummaries(rt);
                if (summaries.length === 0) {
                    ctx.ui.notify("changelog: no recent updates.", "info");
                    return;
                }
                if (!ctx.hasUI) {
                    // Headless / RPC mode — widget surface unavailable; dump text.
                    const view = await computeWhatsNewView(rt, arg.length > 0 ? arg : null);
                    ctx.ui.notify(view, "info");
                    return;
                }
                // Echo the overall summary into the main chat container so
                // users have a record of the change counts, then mount the
                // strip below the editor for arrow-key drill-down.
                ctx.ui.notify(`${renderSummaryPanel(summaries)}\n\n(Strip is below the editor — ↓ on an empty prompt to activate, ←→ to navigate, Enter to expand, Esc to hide, d to forget.)`, "info");
                mountStripWithSummaries(pi, ctx, rt, summaries);
            }
            catch (err) {
                if (process.env.FORGE_DEBUG_WHATS_NEW === "1") {
                    console.error("[forge-cli changelog] handler failed:", err);
                }
                ctx.ui.notify("changelog: failed to render — set FORGE_DEBUG_WHATS_NEW=1 for details.", "warning");
            }
        },
    });
}
//# sourceMappingURL=whats-new-widget.js.map