/**
 * A — orphan tmux sessions: the manager side.
 *
 * An "orphan" is a `web_*` tmux session on the host that this server does NOT have
 * in its table (it outlived the process that made it). These tests pin the three
 * rules that keep the feature safe:
 *
 *  1. never enumerate or act on a tmux session that is not ours (`web_` + UUID v4);
 *  2. never act on an id the table already owns — that path must stay killById, or
 *     we would kill the shell out from under a live PTY and leave a zombie entry;
 *  3. do nothing at all when tmux is off, so the non-tmux deployment is unchanged.
 *
 * Both node-pty and the tmux CLI wrappers are mocked: no shell, no tmux binary.
 */

import { describe, it, expect, beforeEach, vi } from 'vitest';

import type { Config, Dims, WebSocketLike } from '../src/types.js';
import { WS_OPEN } from '../src/types.js';
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';

let nextPty: MockIPty = createMockPty();

vi.mock('node-pty', () => ({
  spawn: () => nextPty,
}));

// ── tmux CLI mock ────────────────────────────────────────────────────────────
const tmuxList = vi.fn<() => Promise<unknown[]>>(async () => []);
const tmuxCapture = vi.fn<(name: string) => Promise<string | null>>(async () => null);
const tmuxKill = vi.fn<(name: string) => void>(() => {});
const tmuxHas = vi.fn<(name: string) => boolean>(() => true);

vi.mock('../src/session/tmux.js', () => ({
  tmuxAvailable: () => true,
  tmuxName: (id: string) => `web_${id}`,
  hasSession: (n: string) => tmuxHas(n),
  killSession: (n: string) => tmuxKill(n),
  listSessions: () => tmuxList(),
  capturePane: (n: string) => tmuxCapture(n),
}));

const { createSessionManager } = await import('../src/session/manager.js');

const U1 = '05caa88b-1f5f-45d9-bbbe-fc5955314870';
const U2 = '3a63f3c7-acfe-4081-992f-c4dbd55b3d6b';

const BASE_CFG = {
  port: 3000,
  bindHost: '0.0.0.0',
  shellPath: '/bin/zsh',
  homeDir: '/home/tester',
  idleTtlMs: 10_000,
  scrollbackBytes: 2 * 1024 * 1024,
  maxPayloadBytes: 1024 * 1024,
  wsPath: '/term',
  maxSessions: 50,
  maxMsgsPerSec: 2000,
  permTimeoutMs: 300_000,
  reapIntervalMs: 60_000,
  previewBytes: 24 * 1024,
  useTmux: true,
  allowedOrigins: [],
  projectRoots: ['/home/tester'],
  projectScanDepth: 4,
  projectScanTtlMs: 10_000,
  projectDirtyCheck: true,
  editorCmd: 'code',
  vapidPublicKey: undefined,
  vapidPrivateKey: undefined,
  vapidSubject: undefined,
  pushStorePath: '/home/tester/.push.json',
  pushMaxSubs: 50,
  notifyDone: true,
  notifyDnd: false,
  decisionTokenTtlMs: 300_000,
  timelineMax: 200,
  timelineEnabled: true,
  stuckTtlMs: 10_000,
  stuckAlert: true,
  diffTimeoutMs: 2000,
  diffMaxBytes: 2 * 1024 * 1024,
  diffMaxFiles: 300,
  statuslineTtlMs: 30_000,
  worktreeEnabled: true,
  worktreeRoot: undefined,
  worktreeTimeoutMs: 10_000,
  maxFanoutLanes: 6,
  defaultPermissionMode: 'default' as const,
  allowAutoMode: false,
  queueEnabled: true,
  queueMaxItems: 10,
  queueItemMaxBytes: 4096,
  queueSettleMs: 1500,
} satisfies Config;

const CFG: Config = BASE_CFG;
const CFG_NO_TMUX: Config = { ...BASE_CFG, useTmux: false };
const DIMS: Dims = { cols: 80, rows: 24 };

function createMockWs(): WebSocketLike & { readyState: number } {
  return {
    readyState: WS_OPEN,
    send() {},
    close() {},
  };
}

/** A tmux summary row as src/session/tmux.ts would produce it. */
function summary(id: string, activityMs = 5_000, attached = false) {
  return {
    id,
    createdAtMs: 1_000,
    lastActivityAtMs: activityMs,
    attached,
    cols: 120,
    rows: 40,
  };
}

beforeEach(() => {
  nextPty = createMockPty();
  tmuxList.mockReset().mockResolvedValue([]);
  tmuxCapture.mockReset().mockResolvedValue(null);
  tmuxKill.mockReset();
  tmuxHas.mockReset().mockReturnValue(true);
});

describe('listOrphans', () => {
  it('reports a tmux session the table does not know about', async () => {
    tmuxList.mockResolvedValue([summary(U1, 9_000, true)]);
    const mgr = createSessionManager(CFG);

    expect(await mgr.listOrphans()).toEqual([
      {
        id: U1,
        createdAt: 1_000,
        lastActivityAt: 9_000,
        attached: true,
        cols: 120,
        rows: 40,
      },
    ]);
  });

  it('excludes sessions the table already owns — those are live, not orphans', async () => {
    const mgr = createSessionManager(CFG);
    const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
    tmuxList.mockResolvedValue([summary(live.meta.id), summary(U2)]);

    expect((await mgr.listOrphans()).map((o) => o.id)).toEqual([U2]);
  });

  it('is empty when tmux is off — the non-tmux deployment is untouched', async () => {
    tmuxList.mockResolvedValue([summary(U1)]);
    const mgr = createSessionManager(CFG_NO_TMUX);

    expect(await mgr.listOrphans()).toEqual([]);
    expect(tmuxList).not.toHaveBeenCalled();
  });
});

describe('captureOrphan', () => {
  it('returns the captured screen for an untracked session', async () => {
    tmuxCapture.mockResolvedValue('screen contents');
    const mgr = createSessionManager(CFG);

    expect(await mgr.captureOrphan(U1)).toBe('screen contents');
    expect(tmuxCapture).toHaveBeenCalledWith(`web_${U1}`);
  });

  it('refuses an id that is not a UUID v4 without invoking tmux at all', async () => {
    const mgr = createSessionManager(CFG);

    expect(await mgr.captureOrphan('../../etc/passwd')).toBeNull();
    expect(await mgr.captureOrphan('-t')).toBeNull();
    expect(tmuxCapture).not.toHaveBeenCalled();
  });

  it('refuses when no such tmux session exists', async () => {
    tmuxHas.mockReturnValue(false);
    const mgr = createSessionManager(CFG);

    expect(await mgr.captureOrphan(U1)).toBeNull();
    expect(tmuxCapture).not.toHaveBeenCalled();
  });

  it('refuses an id the table owns — a live session previews from its ring buffer', async () => {
    const mgr = createSessionManager(CFG);
    const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);

    expect(await mgr.captureOrphan(live.meta.id)).toBeNull();
    expect(tmuxCapture).not.toHaveBeenCalled();
  });

  it('returns null when tmux is off', async () => {
    const mgr = createSessionManager(CFG_NO_TMUX);
    expect(await mgr.captureOrphan(U1)).toBeNull();
    expect(tmuxCapture).not.toHaveBeenCalled();
  });
});

describe('killOrphan', () => {
  it('kills the tmux session by its prefixed name', () => {
    const mgr = createSessionManager(CFG);

    expect(mgr.killOrphan(U1)).toBe(true);
    expect(tmuxKill).toHaveBeenCalledWith(`web_${U1}`);
  });

  it('refuses a non-UUID id without invoking tmux', () => {
    const mgr = createSessionManager(CFG);

    expect(mgr.killOrphan('web_x')).toBe(false);
    expect(mgr.killOrphan('-t')).toBe(false);
    expect(mgr.killOrphan('')).toBe(false);
    expect(tmuxKill).not.toHaveBeenCalled();
  });

  it('refuses an id the table owns — that is killById’s job', () => {
    const mgr = createSessionManager(CFG);
    const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);

    expect(mgr.killOrphan(live.meta.id)).toBe(false);
    expect(tmuxKill).not.toHaveBeenCalled();
  });

  it('reports false when the session is already gone', () => {
    tmuxHas.mockReturnValue(false);
    const mgr = createSessionManager(CFG);

    expect(mgr.killOrphan(U1)).toBe(false);
    expect(tmuxKill).not.toHaveBeenCalled();
  });

  it('does nothing when tmux is off', () => {
    const mgr = createSessionManager(CFG_NO_TMUX);
    expect(mgr.killOrphan(U1)).toBe(false);
    expect(tmuxKill).not.toHaveBeenCalled();
  });
});

describe('killOrphansIdleSince', () => {
  it('kills only sessions whose last activity is older than the cutoff', async () => {
    tmuxList.mockResolvedValue([summary(U1, 1_000), summary(U2, 9_000)]);
    const mgr = createSessionManager(CFG);

    expect(await mgr.killOrphansIdleSince(5_000)).toEqual([U1]);
    expect(tmuxKill).toHaveBeenCalledWith(`web_${U1}`);
    expect(tmuxKill).toHaveBeenCalledTimes(1);
  });

  it('never kills a session someone is attached to from a terminal', async () => {
    // "Untracked by this server" is not "abandoned" — a plain `tmux attach` or a
    // second server process both show up as attached, and bulk cleanup must not
    // reach into either.
    tmuxList.mockResolvedValue([summary(U1, 1_000, true)]);
    const mgr = createSessionManager(CFG);

    expect(await mgr.killOrphansIdleSince(5_000)).toEqual([]);
    expect(tmuxKill).not.toHaveBeenCalled();
  });

  it('never kills a session the table owns, however idle tmux thinks it is', async () => {
    const mgr = createSessionManager(CFG);
    const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
    tmuxList.mockResolvedValue([summary(live.meta.id, 0)]);

    expect(await mgr.killOrphansIdleSince(5_000)).toEqual([]);
    expect(tmuxKill).not.toHaveBeenCalled();
  });

  it('does nothing when tmux is off', async () => {
    tmuxList.mockResolvedValue([summary(U1, 0)]);
    const mgr = createSessionManager(CFG_NO_TMUX);

    expect(await mgr.killOrphansIdleSince(5_000)).toEqual([]);
    expect(tmuxKill).not.toHaveBeenCalled();
  });
});

describe('countOrphansIdleSince', () => {
  it('counts exactly what killOrphansIdleSince would kill, killing nothing', async () => {
    // The UI confirmation must state this number, not the number of cards it is
    // showing: the grid is capped, so counting cards understates the blast radius.
    tmuxList.mockResolvedValue([summary(U1, 1_000), summary(U2, 9_000)]);
    const mgr = createSessionManager(CFG);

    expect(await mgr.countOrphansIdleSince(5_000)).toBe(1);
    expect(tmuxKill).not.toHaveBeenCalled();
  });

  it('does not count attached sessions', async () => {
    tmuxList.mockResolvedValue([summary(U1, 1_000, true), summary(U2, 1_000)]);
    const mgr = createSessionManager(CFG);

    expect(await mgr.countOrphansIdleSince(5_000)).toBe(1);
  });

  it('is 0 when tmux is off', async () => {
    const mgr = createSessionManager(CFG_NO_TMUX);
    expect(await mgr.countOrphansIdleSince(5_000)).toBe(0);
  });
});
