/**
 * Session Manager
 *
 * Owns session lifecycle: creation, destruction, state tracking, and persistence.
 * Extracted from WsBridge to establish a clear separation of concerns:
 * - **SessionManager** owns session CRUD, state mutations, persistence to disk,
 *   message history management, and orchestration callbacks.
 * - **WsBridge** owns WebSocket transport, message dispatch/routing, and
 *   protocol translation.
 *
 * WsBridge delegates session operations to this class while handling
 * WebSocket connections, message dispatch, and protocol translation.
 *
 * Key features:
 * - Per-session debounced git-info refresh (avoids redundant `git` calls)
 * - Disk persistence via {@link SessionStore} (debounced writes)
 * - Callback registration for lifecycle events (CLI session ID, first turn, git info)
 * - Auto-naming trigger after the first successful turn
 */

import { createLogger } from "./logger.js";
import type {
  BrowserIncomingMessage,
  SessionState,
  BackendType,
} from "./session-types.js";
import type { SessionStore } from "./session-store.js";
import type { Session, GitSessionKey } from "./ws-bridge-types.js";
import { makeDefaultState } from "./ws-bridge-types.js";
import { resolveSessionGitInfo } from "./session-git-info.js";

const log = createLogger("session-manager");

/** Callback for broadcasting messages to all browsers connected to a session. */
export type BroadcastFn = (session: Session, msg: BrowserIncomingMessage) => void;

/**
 * Manages session lifecycle, state, persistence, and orchestration callbacks.
 *
 * @see WsBridge — The transport layer that delegates to this class.
 */
export class SessionManager {
  /** Maximum number of messages retained in a session's message history. */
  static readonly MAX_MESSAGE_HISTORY_SIZE = 2_000;
  /** Minimum interval (ms) between git-info refreshes for a single session. */
  private static readonly GIT_REFRESH_DEBOUNCE_MS = 5_000;
  private static readonly GIT_SESSION_KEYS: GitSessionKey[] = [
    "git_branch",
    "is_worktree",
    "is_containerized",
    "repo_root",
    "git_ahead",
    "git_behind",
  ];

  private sessions = new Map<string, Session>();
  private store: SessionStore | null = null;
  private autoNamingAttempted = new Set<string>();
  private userMsgCounter = 0;

  /** Epoch-ms of the last refreshGitInfo execution per session. */
  private gitRefreshLastRun = new Map<string, number>();
  /** Pending trailing-edge timer per session so a debounced call still fires. */
  private gitRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();

  // Orchestration callbacks
  private onCLISessionIdCb: ((sessionId: string, cliSessionId: string) => void) | null = null;
  private onCLIRelaunchNeededCb: ((sessionId: string) => void) | null = null;
  private onFirstTurnCompletedCb: ((sessionId: string, firstUserMessage: string) => void) | null = null;
  private onGitInfoReadyCb: ((sessionId: string, cwd: string, branch: string) => void) | null = null;

  /** Optional broadcast function, set by WsBridge after construction. */
  private broadcastFn: BroadcastFn | null = null;

  // ── Dependency injection ────────────────────────────────────────────────

  /** Set the broadcast function used by refreshGitInfo to push updates to browsers. */
  setBroadcastFn(fn: BroadcastFn): void {
    this.broadcastFn = fn;
  }

  /** Attach a persistent store. Call restoreFromDisk() after. */
  setStore(store: SessionStore): void {
    this.store = store;
  }

  // ── Callback registration ──────────────────────────────────────────────

  /** Register a callback for when we learn the CLI's internal session ID. */
  onCLISessionIdReceived(cb: (sessionId: string, cliSessionId: string) => void): void {
    this.onCLISessionIdCb = cb;
  }

  /** Register a callback for when a browser connects but CLI is dead. */
  onCLIRelaunchNeededCallback(cb: (sessionId: string) => void): void {
    this.onCLIRelaunchNeededCb = cb;
  }

  /** Register a callback for when a session completes its first turn. */
  onFirstTurnCompletedCallback(cb: (sessionId: string, firstUserMessage: string) => void): void {
    this.onFirstTurnCompletedCb = cb;
  }

  /** Register a callback for when git info is resolved and branch is known. */
  onSessionGitInfoReadyCallback(cb: (sessionId: string, cwd: string, branch: string) => void): void {
    this.onGitInfoReadyCb = cb;
  }

  // ── Callback accessors (for passing to sub-modules like ws-bridge-codex) ──

  getOnCLISessionId(): ((sessionId: string, cliSessionId: string) => void) | null {
    return this.onCLISessionIdCb;
  }

  getOnFirstTurnCompleted(): ((sessionId: string, firstUserMessage: string) => void) | null {
    return this.onFirstTurnCompletedCb;
  }

  getOnCLIRelaunchNeeded(): ((sessionId: string) => void) | null {
    return this.onCLIRelaunchNeededCb;
  }

  getAutoNamingAttempted(): Set<string> {
    return this.autoNamingAttempted;
  }

  // ── Session CRUD ───────────────────────────────────────────────────────

  /**
   * Retrieve an existing session by ID or create a new one.
   *
   * @param sessionId - Unique session identifier.
   * @param backendType - Backend type for new sessions; only overrides existing sessions
   *   when explicitly provided (prevents accidental claude→codex resets).
   * @returns The existing or newly created Session object.
   */
  getOrCreateSession(sessionId: string, backendType?: BackendType): Session {
    let session = this.sessions.get(sessionId);
    if (!session) {
      const type = backendType || "claude";
      session = {
        id: sessionId,
        backendType: type,
        cliSocket: null,
        codexAdapter: null,
        browserSockets: new Set(),
        state: makeDefaultState(sessionId, type),
        pendingPermissions: new Map(),
        pendingControlRequests: new Map(),
        messageHistory: [],
        pendingMessages: [],
        nextEventSeq: 1,
        eventBuffer: [],
        lastAckSeq: 0,
        processedClientMessageIds: [],
        processedClientMessageIdSet: new Set(),
        messageQueue: [],
        queuePaused: false,
        keepAliveInterval: null,
        sentMessageBuffer: [],
        sentMessageSeq: 0,
        cliMessageSeqMap: new Map(),
        modifiedFiles: new Set(),
      };
      this.sessions.set(sessionId, session);
    } else if (backendType) {
      // Only overwrite backendType when explicitly provided (e.g. attachCodexAdapter)
      // Prevents handleBrowserOpen from resetting codex→claude
      session.backendType = backendType;
      session.state.backend_type = backendType;
    }
    return session;
  }

  /**
   * @param sessionId - Session to look up.
   * @returns The Session if it exists, undefined otherwise.
   */
  getSession(sessionId: string): Session | undefined {
    return this.sessions.get(sessionId);
  }

  /** @returns An array of all active session state snapshots. */
  getAllSessions(): SessionState[] {
    return Array.from(this.sessions.values()).map((s) => s.state);
  }

  /** Return all raw Session objects (used by WsBridge for socket cleanup during shutdown). */
  getAllSessionEntries(): Session[] {
    return Array.from(this.sessions.values());
  }

  getCodexRateLimits(sessionId: string) {
    const session = this.sessions.get(sessionId);
    return session?.codexAdapter?.getRateLimits() ?? null;
  }

  /**
   * @param sessionId - Session to check.
   * @returns `true` if an active CLI or Codex connection exists.
   */
  isCliConnected(sessionId: string): boolean {
    const session = this.sessions.get(sessionId);
    if (!session) return false;
    if (session.backendType === "codex") {
      return !!session.codexAdapter?.isConnected();
    }
    return !!session.cliSocket;
  }

  /**
   * Remove a session from the in-memory map and delete its persisted file.
   *
   * @param sessionId - Session to remove.
   */
  removeSession(sessionId: string): void {
    this.clearGitRefreshDebounce(sessionId);
    this.sessions.delete(sessionId);
    this.autoNamingAttempted.delete(sessionId);
    this.store?.remove(sessionId);
  }

  /** Clear all sessions from the map and auto-naming tracker. Used during shutdown. */
  clearAll(): void {
    for (const timer of this.gitRefreshTimers.values()) {
      clearTimeout(timer);
    }
    this.gitRefreshTimers.clear();
    this.gitRefreshLastRun.clear();
    this.sessions.clear();
    this.autoNamingAttempted.clear();
  }

  // ── Session state mutations ────────────────────────────────────────────

  /**
   * Pre-populate a session with container info so that handleSystemMessage
   * preserves the host cwd instead of overwriting it with /workspace.
   * Call this right after launcher.launch() for containerized sessions.
   */
  markContainerized(sessionId: string, hostCwd: string): void {
    const session = this.getOrCreateSession(sessionId);
    session.state.is_containerized = true;
    session.state.cwd = hostCwd;
  }

  /**
   * Set the USD budget cap on a session.
   * Call this right after launcher.launch() so the budget is tracked
   * before the first result message arrives.
   */
  setBudget(sessionId: string, budgetUsd: number): void {
    const session = this.getOrCreateSession(sessionId);
    session.state.budget_usd = budgetUsd;
  }

  // ── History management ─────────────────────────────────────────────────

  /** Append a message to session history, trimming oldest entries if over the cap. */
  appendToHistory(session: Session, msg: BrowserIncomingMessage): void {
    session.messageHistory.push(msg);
    if (session.messageHistory.length > SessionManager.MAX_MESSAGE_HISTORY_SIZE) {
      const excess = session.messageHistory.length - SessionManager.MAX_MESSAGE_HISTORY_SIZE;
      session.messageHistory.splice(0, excess);
    }
  }

  // ── User message counter ──────────────────────────────────────────────

  nextUserMsgId(): number {
    return this.userMsgCounter++;
  }

  // ── Persistence ────────────────────────────────────────────────────────

  /**
   * Persist a session's current state to disk via the SessionStore.
   * Writes are debounced (150ms throttle) to batch rapid updates during streaming.
   *
   * @param session - The session to persist.
   */
  persistSession(session: Session): void {
    if (!this.store) return;
    this.store.save({
      id: session.id,
      state: session.state,
      messageHistory: session.messageHistory,
      pendingMessages: session.pendingMessages,
      pendingPermissions: Array.from(session.pendingPermissions.entries()),
      eventBuffer: session.eventBuffer,
      nextEventSeq: session.nextEventSeq,
      lastAckSeq: session.lastAckSeq,
      processedClientMessageIds: session.processedClientMessageIds,
      messageQueue: session.messageQueue,
      queuePaused: session.queuePaused,
    });
  }

  /**
   * Restore sessions from disk at startup.
   * Skips sessions that already exist in-memory (e.g., from early connections).
   * Fires async git-info resolution for each restored session.
   *
   * @returns The number of sessions successfully restored.
   */
  async restoreFromDisk(): Promise<number> {
    if (!this.store) return 0;
    const persisted = await this.store.loadAll();
    let count = 0;
    for (const p of persisted) {
      if (this.sessions.has(p.id)) continue; // don't overwrite live sessions
      const session: Session = {
        id: p.id,
        backendType: p.state.backend_type || "claude",
        cliSocket: null,
        codexAdapter: null,
        browserSockets: new Set(),
        state: p.state,
        pendingPermissions: new Map(p.pendingPermissions || []),
        pendingControlRequests: new Map(),
        messageHistory: p.messageHistory || [],
        pendingMessages: p.pendingMessages || [],
        nextEventSeq: p.nextEventSeq && p.nextEventSeq > 0 ? p.nextEventSeq : 1,
        eventBuffer: Array.isArray(p.eventBuffer) ? p.eventBuffer : [],
        lastAckSeq: typeof p.lastAckSeq === "number" ? p.lastAckSeq : 0,
        processedClientMessageIds: Array.isArray(p.processedClientMessageIds) ? p.processedClientMessageIds : [],
        processedClientMessageIdSet: new Set(
          Array.isArray(p.processedClientMessageIds) ? p.processedClientMessageIds : [],
        ),
        messageQueue: Array.isArray(p.messageQueue) ? p.messageQueue : [],
        queuePaused: typeof p.queuePaused === "boolean" ? p.queuePaused : false,
        keepAliveInterval: null,
        sentMessageBuffer: [],
        sentMessageSeq: 0,
        cliMessageSeqMap: new Map(),
      };
      session.state.backend_type = session.backendType;
      // Fire-and-forget async git info resolution for restored sessions.
      // Browser connect will re-resolve if needed before this completes.
      void resolveSessionGitInfo(session.id, session.state);
      this.sessions.set(p.id, session);
      // Restored sessions with completed turns don't need auto-naming re-triggered
      if (session.state.num_turns > 0) {
        this.autoNamingAttempted.add(session.id);
      }
      count++;
    }
    if (count > 0) {
      log.info({ count }, "Restored sessions from disk");
    }
    return count;
  }

  // ── Git info resolution ────────────────────────────────────────────────

  /**
   * Refresh git info for a session with per-session debounce.
   *
   * Uses a leading-edge + trailing-edge pattern: the first call within the
   * debounce window executes immediately; subsequent calls are collapsed and
   * a trailing-edge timer ensures the latest request fires once the window elapses.
   *
   * @param session - The session whose git info should be refreshed.
   * @param options - Control whether to broadcast updates and notify the PR poller.
   */
  async refreshGitInfo(
    session: Session,
    options: { broadcastUpdate?: boolean; notifyPoller?: boolean } = {},
  ): Promise<void> {
    const now = Date.now();
    const lastRun = this.gitRefreshLastRun.get(session.id) ?? 0;
    const elapsed = now - lastRun;

    if (elapsed < SessionManager.GIT_REFRESH_DEBOUNCE_MS) {
      // Within debounce window — schedule a trailing-edge refresh so the
      // most recent options are honoured once the window closes.
      if (!this.gitRefreshTimers.has(session.id)) {
        const delay = SessionManager.GIT_REFRESH_DEBOUNCE_MS - elapsed;
        const timer = setTimeout(() => {
          this.gitRefreshTimers.delete(session.id);
          void this.refreshGitInfo(session, options);
        }, delay);
        this.gitRefreshTimers.set(session.id, timer);
      }
      return;
    }

    // Clear any pending trailing timer — we're executing now.
    const pending = this.gitRefreshTimers.get(session.id);
    if (pending) {
      clearTimeout(pending);
      this.gitRefreshTimers.delete(session.id);
    }

    this.gitRefreshLastRun.set(session.id, now);
    await this._doRefreshGitInfo(session, options);
  }

  /** Actual git-info resolution (no debounce logic). */
  private async _doRefreshGitInfo(
    session: Session,
    options: { broadcastUpdate?: boolean; notifyPoller?: boolean },
  ): Promise<void> {
    const before = {
      git_branch: session.state.git_branch,
      is_worktree: session.state.is_worktree,
      is_containerized: session.state.is_containerized,
      repo_root: session.state.repo_root,
      git_ahead: session.state.git_ahead,
      git_behind: session.state.git_behind,
    };

    await resolveSessionGitInfo(session.id, session.state);

    let changed = false;
    for (const key of SessionManager.GIT_SESSION_KEYS) {
      if (session.state[key] !== before[key]) {
        changed = true;
        break;
      }
    }

    if (changed) {
      if (options.broadcastUpdate && this.broadcastFn) {
        this.broadcastFn(session, {
          type: "session_update",
          session: {
            git_branch: session.state.git_branch,
            is_worktree: session.state.is_worktree,
            is_containerized: session.state.is_containerized,
            repo_root: session.state.repo_root,
            git_ahead: session.state.git_ahead,
            git_behind: session.state.git_behind,
          },
        });
      }
      this.persistSession(session);
    }

    if (options.notifyPoller && session.state.git_branch && session.state.cwd && this.onGitInfoReadyCb) {
      this.onGitInfoReadyCb(session.id, session.state.cwd, session.state.git_branch);
    }
  }

  /** Clean up debounce timers for a destroyed session. */
  clearGitRefreshDebounce(sessionId: string): void {
    const timer = this.gitRefreshTimers.get(sessionId);
    if (timer) {
      clearTimeout(timer);
      this.gitRefreshTimers.delete(sessionId);
    }
    this.gitRefreshLastRun.delete(sessionId);
  }
}
