import type { UserSession } from './types';
import { logger } from './logger';
import { clearOpenCodeSession } from './opencode/state';

class SessionManager {
  private sessions = new Map<string, UserSession>();
  private userSessions = new Map<string, string[]>();

  getOrCreate(
    sessionId: string,
    email: string,
    cwd: string,
    model: string,
    groupSlug?: string | null,
    context?: string,
    contextId?: string,
  ): UserSession {
    let session = this.sessions.get(sessionId);

    if (!session) {
      session = {
        sessionId,
        email,
        cwd,
        model,
        piProcess: null,
        sockets: new Set(),
        lastActivity: Date.now(),
        idleTimer: null,
        streamBuffer: '',
        isGenerating: false,
        systemContextSent: false,
        messages: [],
        meta: {
          id: sessionId,
          title: '',
          model,
          cwd,
          groupSlug: groupSlug || null,
          context,
          contextId,
          createdAt: Date.now(),
          updatedAt: Date.now(),
          messageCount: 0,
          cost: {
            inputTokens: 0,
            outputTokens: 0,
            totalUSD: 0,
          },
        },
      };

      this.sessions.set(sessionId, session);

      const userSessionIds = this.userSessions.get(email) || [];
      userSessionIds.push(sessionId);
      this.userSessions.set(email, userSessionIds);
    }

    session.lastActivity = Date.now();
    return session;
  }

  getSession(sessionId: string): UserSession | null {
    return this.sessions.get(sessionId) || null;
  }

  getUserSessions(email: string): UserSession[] {
    const sessionIds = this.userSessions.get(email) || [];
    return sessionIds.map((id) => this.sessions.get(id)).filter((s): s is UserSession => s !== undefined);
  }

  /** Drop this process's record of a session. Shared by `deleteSession` and `releaseSession`. */
  private forget(session: UserSession): void {
    if (session.idleTimer) {
      clearTimeout(session.idleTimer);
    }

    this.sessions.delete(session.sessionId);

    const userSessionIds = this.userSessions.get(session.email);
    if (userSessionIds) {
      const filtered = userSessionIds.filter((id) => id !== session.sessionId);
      if (filtered.length > 0) {
        this.userSessions.set(session.email, filtered);
      } else {
        this.userSessions.delete(session.email);
      }
    }
  }

  /**
   * End the session for good: kill the agent upstream, drop the subscription, forget the record. This is
   * what an explicit "disconnect" means — the user said stop.
   */
  deleteSession(sessionId: string): void {
    const session = this.sessions.get(sessionId);
    if (!session) return;

    // Clean up sidecar subscriptions
    if (session._sidecarUnsub) {
      session._sidecarUnsub();
    }
    if (session._claudeKill) {
      session._claudeKill();
    }

    // The OpenCode sessionKey → `ses_…` map is keyed on our session id and nothing else cleared it, so
    // it grew for the process lifetime and a REUSED key silently resumed a stale OpenCode session.
    // Only on deleteSession, never on releaseSession: releasing is "let go, leave it running", and a
    // returning browser has to be able to find the same `ses_…` again.
    clearOpenCodeSession(sessionId);

    this.forget(session);
  }

  /**
   * Let go WITHOUT killing: unsubscribe, forget the record, leave the agent running.
   *
   * This is what the idle GC should always have done. Officer's timer was doing two unrelated jobs —
   * collecting its own in-memory binding, which is its business, and terminating the agent, which is the
   * sidecar's. The sidecar already refuses to collect a session that is mid-turn or holding background
   * tasks (`claude-manager.ts` → `pendingTasks`, and an `armIdle` that re-checks rather than firing
   * once); officer knew none of that and killed anyway. A `run_in_background` job outliving the browser
   * — a laptop that ran out of battery — died an hour later for no reason.
   *
   * Nothing is stranded by forgetting the record. The sidecar keeps committing to `chat_session_events`,
   * and a returning browser goes through `adoptOrphanedSession`, which rebuilds the record and a fresh
   * subscription. That is the same path every `pm2 restart officer` already takes.
   */
  releaseSession(sessionId: string): void {
    const session = this.sessions.get(sessionId);
    if (!session) return;

    // Only the listener goes. `_claudeKill` is deliberately NOT called — and must not be left behind
    // either: the record is being dropped, so the next turn starts from `adoptOrphanedSession`, which
    // installs its own kill and subscription.
    if (session._sidecarUnsub) {
      session._sidecarUnsub();
    }

    this.forget(session);
  }

  attachWs(sessionId: string, ws: any): void {
    const session = this.sessions.get(sessionId);
    if (session) {
      session.sockets.add(ws);
      session.lastActivity = Date.now();

      if (session.idleTimer) {
        clearTimeout(session.idleTimer);
        session.idleTimer = null;
      }
    }
  }

  /**
   * Removes one socket. The caller must say WHICH — a bare `detachWs(sessionId)` used to null the
   * session's only socket field, so a stale client's close event silenced whichever client had attached
   * after it. A close is only the end of the conversation when nothing else is still watching.
   */
  detachWs(sessionId: string, ws: any): void {
    const session = this.sessions.get(sessionId);
    if (session) {
      session.sockets.delete(ws);
      session.lastActivity = Date.now();
    }
  }

  /** Whether anything is still watching — the idle GC must not start while another client is attached. */
  hasSockets(sessionId: string): boolean {
    return (this.sessions.get(sessionId)?.sockets.size ?? 0) > 0;
  }

  setIdleTimeout(sessionId: string, timeoutMs: number): void {
    const session = this.sessions.get(sessionId);
    if (!session) return;

    if (session.idleTimer) {
      clearTimeout(session.idleTimer);
    }

    session.idleTimer = setTimeout(() => {
      logger.info('Session idle timeout reached, releasing binding (agent left running)', {
        sessionId,
        timeoutMs,
      });
      this.releaseSession(sessionId);
    }, timeoutMs);
  }

  updateActivity(sessionId: string): void {
    const session = this.sessions.get(sessionId);
    if (session) {
      session.lastActivity = Date.now();
    }
  }

  getAllSessions(): UserSession[] {
    return Array.from(this.sessions.values());
  }
}

export const sessionManager = new SessionManager();
