/**
 * SessionRefresh — orchestrates an agent-initiated session respawn.
 *
 * When an agent installs a new MCP server or skill mid-session, Claude Code
 * only attaches the new tools at session start. The agent triggers this class
 * to kill its current tmux session and respawn it with `claude --resume <uuid>`,
 * which loads the freshly installed tools while preserving full conversation
 * state.
 *
 * The respawn lifecycle is owned end-to-end here:
 *   detect    — sanity-check session, topic binding, resume UUID exist
 *   attempt   — apply rate guard, persist resume UUID, kill tmux, respawn
 *   verify    — new session is registered for the topic
 *   finalize  — return structured result
 *
 * Rate guard is a structural rate-counter (allowed-detector category per
 * docs/signal-vs-authority.md "safety guards on irreversible actions"
 * carve-out) — prevents infinite-respawn loops. Not a judgment call.
 *
 * Scope: Telegram-bound AND Slack-bound sessions (TOPIC-PROFILE-SPEC §10.5 —
 * Slack parity is an explicit prerequisite, not a v2 follow-up). Binding
 * resolution checks Telegram first (in-memory map, then a fresh disk read of
 * the persisted topic-session registry — so a binding registered after this
 * process loaded the registry, e.g. on a --no-telegram server, is still
 * recoverable), then the Slack channel/thread registry via the optional
 * `slack` dep. Slack respawns go through the optional `slackRespawner`
 * callback; when a session is Slack-bound but no slackRespawner is wired the
 * refusal is the structured `slack_respawner_unwired` (so callers can degrade
 * to CONTINUATION-on-next-message, disclosed honestly, per §10.5). Genuinely
 * unbound sessions (iMessage, headless) return
 * { ok: false, code: 'not_telegram_bound' } (code kept for back-compat).
 */
import fs from 'node:fs';
import path from 'node:path';
import { ensureInteractiveReady } from './ensureInteractiveReady.js';
import { slackConversationKey, slackRoutingKeySyntheticId, } from './slackRefreshBinding.js';
/**
 * Account-swap conversation continuity. Claude stores conversation transcripts
 * PER CONFIG HOME (`<CLAUDE_CONFIG_DIR>/projects/<projectDir>/<uuid>.jsonl`), so a
 * quota swap that changes CLAUDE_CONFIG_DIR and then runs `claude --resume <uuid>`
 * finds "No conversation found" — the transcript is still in the OLD account's
 * config home. (The resume UUID is account-agnostic, but the transcript STORAGE
 * is config-home-local — the gap a mocked refresh test can't see.) Before the
 * respawn, copy the transcript into the target config home so --resume succeeds.
 *
 * Self-contained: finds the transcript by uuid across the user's `~/.claude*`
 * config homes (default + enrollment-wizard slots) and copies it, preserving the
 * `projects/<projectDir>/` relative path. Idempotent (no-op if already present),
 * best-effort (never throws). Returns true if the transcript is in the target
 * afterward.
 */
function transcriptRelPath(projectsDir, uuid) {
    try {
        for (const proj of fs.readdirSync(projectsDir)) {
            const f = path.join(projectsDir, proj, `${uuid}.jsonl`);
            if (fs.existsSync(f))
                return path.join(proj, `${uuid}.jsonl`);
        }
    }
    catch { /* @silent-fallback-ok: missing/unreadable projects dir */ }
    return null;
}
export function ensureResumeTranscriptInConfigHome(uuid, targetConfigHome) {
    try {
        const home = process.env.HOME || '';
        const target = targetConfigHome.startsWith('~')
            ? path.join(home, targetConfigHome.slice(1))
            : targetConfigHome;
        const targetProjects = path.join(target, 'projects');
        if (transcriptRelPath(targetProjects, uuid))
            return true; // already there → no-op
        let homes = [];
        try {
            homes = fs.readdirSync(home)
                .filter((n) => n === '.claude' || n.startsWith('.claude-'))
                .map((n) => path.join(home, n));
        }
        catch { /* @silent-fallback-ok: HOME unreadable */ }
        for (const ch of homes) {
            if (path.resolve(ch) === path.resolve(target))
                continue;
            const rel = transcriptRelPath(path.join(ch, 'projects'), uuid);
            if (rel) {
                const dst = path.join(targetProjects, rel);
                fs.mkdirSync(path.dirname(dst), { recursive: true });
                fs.copyFileSync(path.join(ch, 'projects', rel), dst);
                return true;
            }
        }
        return false;
    }
    catch {
        // @silent-fallback-ok: continuity-copy is best-effort; a failure means the
        // swap may start fresh (logged by the caller), not crash the refresh.
        return false;
    }
}
const DEFAULT_MAX_PER_WINDOW = 5;
const DEFAULT_WINDOW_MS = 10 * 60 * 1000;
export class SessionRefresh {
    deps;
    maxPerWindow;
    windowMs;
    clock;
    recentRefreshes = new Map();
    /** In-flight refresh guard — prevents the race where a second call
     *  fires before the first's kill+spawn completes, which would spawn
     *  two parallel sessions for the same topic. */
    inFlight = new Set();
    constructor(deps) {
        this.deps = deps;
        this.maxPerWindow = deps.rateLimit?.maxPerWindow ?? DEFAULT_MAX_PER_WINDOW;
        this.windowMs = deps.rateLimit?.windowMs ?? DEFAULT_WINDOW_MS;
        this.clock = deps.clock ?? Date.now;
    }
    /**
     * Refresh a session: kill its tmux session (which fires beforeSessionKill
     * so the existing listener persists the Claude UUID via TopicResumeMap),
     * then respawn via the injected respawner which spawns a fresh tmux that
     * runs `claude --resume <uuid>` — picking up newly installed MCPs/skills
     * while preserving the full conversation.
     *
     * Returns a structured result; never throws on the expected failure modes
     * (rate-limit, session-not-found, non-Telegram-bound). Throws only on
     * unexpected internal errors from the respawner callback.
     */
    async refreshSession(opts) {
        const { sessionName, followUpPrompt, reason, fresh } = opts;
        // ── detect ─────────────────────────────────────────────────────────
        if (!this.deps.telegram && !this.deps.slack) {
            return {
                ok: false,
                code: 'no_telegram_adapter',
                message: 'No Telegram adapter wired (and no Slack binding either) — self-refresh requires a platform-bound session.',
            };
        }
        let topicId = null;
        if (this.deps.telegram) {
            topicId = this.deps.telegram.getTopicForSession(sessionName);
            if (topicId === null) {
                // In-memory miss does NOT mean the session is unbound. A binding
                // registered after this process loaded the registry won't be in the
                // in-memory reverse map — most importantly on a `--no-telegram` server,
                // whose map reflects only its boot-time snapshot while the lifeline keeps
                // writing new bindings to disk. That is exactly the gap that left wedged
                // long-lived dev sessions (e.g. the Codey collaboration session, topic
                // 13435) un-recoverable: getTopicForSession returned null, recovery bailed
                // with not_telegram_bound, and the dead session stayed dead. Fall back to
                // a fresh disk-backed reverse lookup before giving up.
                topicId = this.deps.telegram.resolveTopicForSessionFromDisk?.(sessionName) ?? null;
            }
        }
        // §10.5 Slack arm: consulted only after BOTH Telegram lookups miss, so a
        // Telegram-bound session resolves exactly as before. The routing key is
        // `<channelId>` or `<channelId>:<thread_ts>` — the same key the kill-time
        // beforeSessionKill listener saves the channel-resume entry under.
        let slackRoutingKey = null;
        if (topicId === null && this.deps.slack) {
            slackRoutingKey = this.deps.slack.getChannelForSession(sessionName);
            if (slackRoutingKey === null) {
                // Disk-backed fallback parity with the Telegram arm (optional method —
                // adapters without it just skip the fallback).
                slackRoutingKey = this.deps.slack.resolveChannelForSessionFromDisk?.(sessionName) ?? null;
            }
        }
        if (topicId === null && slackRoutingKey === null) {
            // Genuinely unbound (no binding on either platform): iMessage/headless
            // sessions remain a follow-up — the respawn path is built around
            // conversation-key → context routing. Code kept as 'not_telegram_bound'
            // for back-compat with existing consumers/log greps.
            return {
                ok: false,
                code: 'not_telegram_bound',
                message: `Session "${sessionName}" is not bound to a Telegram topic${this.deps.slack ? ' or Slack conversation' : ''} (checked in-memory + disk registry); cannot self-refresh.`,
            };
        }
        if (topicId === null && slackRoutingKey !== null && !this.deps.slackRespawner) {
            // Slack-bound, but the Slack respawner isn't wired on this server.
            // Structured refusal (NOT a throw) so the caller can degrade honestly —
            // §10.5: "a respawn-requiring change on a Slack topic degrades to
            // CONTINUATION-on-next-message, disclosed honestly."
            return {
                ok: false,
                code: 'slack_respawner_unwired',
                message: `Session "${sessionName}" is Slack-bound (${slackConversationKey(slackRoutingKey)}) but no Slack respawner is wired — the session will resume via CONTINUATION on the next message instead of an immediate respawn.`,
            };
        }
        // Look up the state session by tmux name — needed for killSession,
        // which takes the state session id, not the tmux session name.
        const stateSession = this.deps.state.listSessions({ status: 'running' })
            .find(s => s.tmuxSession === sessionName);
        if (!stateSession) {
            return {
                ok: false,
                code: 'session_not_found',
                message: `No running session found for tmux name "${sessionName}".`,
            };
        }
        // ── in-flight guard ────────────────────────────────────────────────
        if (this.inFlight.has(sessionName)) {
            return {
                ok: false,
                code: 'refresh_in_progress',
                message: `A refresh is already in progress for "${sessionName}".`,
            };
        }
        // ── rate guard ─────────────────────────────────────────────────────
        if (!this.checkRateLimit(sessionName)) {
            this.logRateLimit(sessionName, reason);
            return {
                ok: false,
                code: 'rate_limited',
                message: `Refresh rate limit exceeded (${this.maxPerWindow} per ${Math.round(this.windowMs / 60000)} minutes) for session "${sessionName}".`,
            };
        }
        // ── attempt ────────────────────────────────────────────────────────
        // Record the attempt against the rate guard window BEFORE the work —
        // we count attempts, not successes, so a flapping respawner can't
        // bypass the cap. Also mark in-flight so a parallel call refuses.
        this.recordRefresh(sessionName);
        this.inFlight.add(sessionName);
        try {
            // Kill via sessionManager so the beforeSessionKill listener fires
            // and persists the resume UUID using session.claudeSessionId. This
            // replaces the previous SessionRefresh-side findUuidForSession+save
            // dance, which would silently no-op (findUuidForSession requires
            // claudeSessionId as second arg; without it, the mtime fallback was
            // removed deliberately and the call returns null).
            this.deps.sessionManager.killSession(stateSession.id);
            // Fresh respawn: drop the resume UUID that beforeSessionKill just saved,
            // so the respawner finds no entry and spawns a brand-new conversation
            // instead of `--resume`-ing the corrupted transcript. MUST run after the
            // kill (beforeSessionKill writes the entry) and before the respawner
            // reads it. Platform-routed: Telegram entries live in TopicResumeMap;
            // Slack entries live in the adapter's channel-resume map keyed on the
            // routing key (the same key beforeSessionKill saved under).
            if (fresh) {
                if (topicId !== null) {
                    this.deps.topicResumeMap?.remove(topicId);
                    console.log(`[SessionRefresh] fresh respawn — cleared resume UUID for topic ${topicId} (sessionName=${sessionName})`);
                }
                else if (slackRoutingKey !== null) {
                    this.deps.slack?.removeChannelResume(slackRoutingKey);
                    console.log(`[SessionRefresh] fresh respawn — cleared Slack channel resume for ${slackConversationKey(slackRoutingKey)} (sessionName=${sessionName})`);
                }
            }
            // The respawner spawns a fresh tmux session that runs `claude
            // --resume <uuid>` (resolved by spawnSessionForTopic via the saved
            // TopicResumeMap entry) and re-registers the topic mapping. With
            // `fresh`, the entry was just cleared, so it spawns without --resume.
            const accountSwap = (opts.configHome || opts.accountId)
                ? { configHome: opts.configHome, accountId: opts.accountId }
                : undefined;
            // Onboarding-safe swap (2026-06-09 incident): the target config home was
            // enrolled headlessly, so it has OAuth tokens but NOT the interactive
            // first-launch flags — relaunching an interactive session into it would
            // wedge on the onboarding screens. Seed the flags BEFORE the respawn.
            // Applies to `fresh` too (the new session is interactive either way).
            // Idempotent + fail-safe: a failure here is logged and the respawn
            // proceeds (worst case is the pre-fix behavior, not a dead refresh).
            if (accountSwap?.configHome) {
                const ready = ensureInteractiveReady(accountSwap.configHome);
                console.log(`[SessionRefresh] account-swap onboarding-readiness: ${accountSwap.configHome} ` +
                    `${ready.patched ? `patched (${ready.reason})` : ready.reason} (sessionName=${sessionName})`);
            }
            // Account-swap continuity: claude stores transcripts per config home, so a
            // swap to a new CLAUDE_CONFIG_DIR must carry the conversation transcript
            // across or `--resume` finds nothing. Skip on `fresh` (we intentionally
            // start a new conversation then). Best-effort: a miss just means the
            // resumed session starts fresh — logged, never fatal.
            if (accountSwap?.configHome && !fresh) {
                const resumeUuid = stateSession.claudeSessionId;
                if (resumeUuid) {
                    const ok = ensureResumeTranscriptInConfigHome(resumeUuid, accountSwap.configHome);
                    console.log(`[SessionRefresh] account-swap continuity: transcript ${ok ? 'ensured in' : 'NOT found for'} ${accountSwap.configHome} (uuid=${resumeUuid}, sessionName=${sessionName})`);
                }
                else {
                    console.log(`[SessionRefresh] account-swap continuity: no claudeSessionId on session "${sessionName}" — cannot pre-copy transcript; resumed session may start fresh`);
                }
            }
            // §10.5 Slack respawn path. The slackRespawner mirrors the Slack
            // message-handler spawn: read+consume getChannelResume(routingKey) and
            // spawnInteractiveSession with the resume UUID + channel/thread opts,
            // then re-register the routing-key → session binding. Guarded non-null
            // by the slack_respawner_unwired refusal in detect.
            if (topicId === null && slackRoutingKey !== null) {
                const newSessionName = await this.deps.slackRespawner(sessionName, slackRoutingKey, followUpPrompt, accountSwap);
                const conversationKey = slackConversationKey(slackRoutingKey);
                // ── verify + finalize (Slack) ──────────────────────────────────
                console.log(`[SessionRefresh] Refreshed "${sessionName}" → "${newSessionName}" (${conversationKey})${reason ? ` reason="${reason}"` : ''}`);
                return {
                    ok: true,
                    newSessionName,
                    topicId: slackRoutingKeySyntheticId(slackRoutingKey),
                    platform: 'slack',
                    conversationKey,
                };
            }
            const newSessionName = await this.deps.respawner(sessionName, topicId, followUpPrompt, accountSwap);
            // ── verify + finalize ────────────────────────────────────────────
            console.log(`[SessionRefresh] Refreshed "${sessionName}" → "${newSessionName}" (topic ${topicId})${reason ? ` reason="${reason}"` : ''}`);
            return { ok: true, newSessionName, topicId: topicId };
        }
        finally {
            this.inFlight.delete(sessionName);
        }
    }
    /**
     * Returns true if a fresh refresh is allowed under the rolling window cap.
     * Pure read of the recent-refresh ledger; does not mutate.
     */
    checkRateLimit(sessionName) {
        const now = this.clock();
        const recent = this.recentRefreshes.get(sessionName) ?? [];
        const fresh = recent.filter(ts => now - ts < this.windowMs);
        if (fresh.length !== recent.length) {
            // Prune stale entries opportunistically.
            this.recentRefreshes.set(sessionName, fresh);
        }
        return fresh.length < this.maxPerWindow;
    }
    /** Append a timestamp to the rolling window for this session. */
    recordRefresh(sessionName) {
        const now = this.clock();
        const recent = this.recentRefreshes.get(sessionName) ?? [];
        recent.push(now);
        this.recentRefreshes.set(sessionName, recent);
    }
    /**
     * Structured log when the rate guard blocks a request. Logged at warn
     * level so over-blocks are detectable in operations (per
     * docs/signal-vs-authority.md "Authorities must log their decisions").
     */
    logRateLimit(sessionName, reason) {
        console.warn(`[SessionRefresh] rate_limited sessionName=${sessionName} window=${this.windowMs}ms cap=${this.maxPerWindow}${reason ? ` reason=${reason}` : ''}`);
    }
}
//# sourceMappingURL=SessionRefresh.js.map