import { createRequire } from 'node:module';
import type { Terminal as HeadlessTerminalType } from '@xterm/headless';
import type { Client, ClientChannel } from 'ssh2';
import { ByteRingBuffer } from './ring-buffer.js';
/** Where an input chunk came from — drives audit `source` + back-pressure label. */
export type InputSource = 'human' | 'ai';
import type { SessionCloseReason } from './console-protocol.js';
import type { SshAuditRepo } from './audit-repo.js';
import { logger } from '../logger.js';

// @xterm/headless is CommonJS-only — named ESM import fails at runtime with
// "Named export 'Terminal' not found" even though TypeScript types resolve.
// Load via createRequire (same pattern as ssh2 / crypto modules in this repo).
const cjsRequire = createRequire(import.meta.url);
const { Terminal: HeadlessTerminal } = cjsRequire('@xterm/headless') as {
  Terminal: typeof HeadlessTerminalType;
};
type HeadlessTerminal = HeadlessTerminalType;

export interface ConsoleSessionArgs {
  localTaskId: string;
  connectionId: string;
  ownerId: string | null;
  startedByUserId: string;
  cols: number;
  rows: number;
  scrollbackCap: number;
  channel: ClientChannel;
  /**
   * The ssh2 Client backing `channel`. The session owns it: it ends the
   * client on close() (so the underlying connection/socket doesn't leak)
   * and reacts to client-level 'error'/'close' so a dropped transport tears
   * the session down instead of leaving a half-dead session registered.
   */
  client: Client;
  auditRepo: SshAuditRepo;
}

export interface ScreenSnapshot {
  cols: number;
  rows: number;
  text: string;
  cursor: { x: number; y: number };
}

/**
 * Per-WebSocket viewer handle registered to a ConsoleSession. Used so the
 * registry can selectively kick viewers (e.g. when a grant is revoked) without
 * killing the underlying SSH session that other viewers / the agent still use.
 */
export interface ViewerHandle {
  /** Acting user the WS authenticated as (req.user.id at upgrade time). */
  userId: string;
  /** Closes the WS with a structured close message; idempotent if already closed. */
  close: (reason: SessionCloseReason) => void;
}

export interface ScrollbackSnapshot {
  text: string;
  byteCount: number;
  truncated: boolean;
}

/**
 * Replace every LF (0x0a) byte with CR (0x0d). Applied to AI input only —
 * the PTY's ICRNL flag translates CR→NL for the shell's readline, but the
 * reverse (LF→NL on input) does not happen in cooked mode. Browser xterm
 * sends CR on Enter, so this normalization makes AI and human input
 * indistinguishable downstream.
 *
 * Allocates a new Buffer (never mutates input). Returns the original
 * reference if no LF is present (fast path for control chars and partial
 * inputs).
 */
function normalizeLfToCr(buf: Buffer): Buffer {
  if (buf.indexOf(0x0a) === -1) return buf;
  const out = Buffer.from(buf);
  for (let i = 0; i < out.length; i++) {
    if (out[i] === 0x0a) out[i] = 0x0d;
  }
  return out;
}

/**
 * Strip common ANSI escape sequences (CSI, OSC, SGR-style) so the AI can read
 * scrollback as plain text. This is permissive on purpose — we strip the
 * common shapes seen from interactive shells (bash/zsh prompts, ls --color,
 * tput) rather than implement a full xterm parser. The headless xterm
 * terminal already gives us the rendered screen for screen snapshots; this
 * helper is only used for the longer raw byte history.
 */
function stripAnsi(s: string): string {
  return (
    s
      // CSI: ESC '[' parameters intermediate final-byte
      .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
      // OSC: ESC ']' ... BEL  or ESC ']' ... ESC \
      .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
      // Other 2-byte ESC sequences (ESC + single char in 0x40-0x5F range,
      // excluding '[' and ']' which were already handled above).
      .replace(/\x1b[@-Z\\^_]/g, '')
      // Lone control bytes (BEL, BS, VT, FF, SO, SI etc.) — keep TAB/LF/CR.
      .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
  );
}

export class ConsoleSession {
  readonly localTaskId: string;
  readonly connectionId: string;
  readonly ownerId: string | null;
  readonly startedByUserId: string;
  readonly startedAt: number;

  cols: number;
  rows: number;

  private readonly channel: ClientChannel;
  private readonly client: Client;
  private readonly headless: HeadlessTerminal;
  private readonly scrollback: ByteRingBuffer;
  private readonly auditRepo: SshAuditRepo;

  private _lastActivityAt: number;
  private _totalInputBytes = 0;
  private _totalOutputBytes = 0;

  private closing = false;
  private closed = false;
  private outputListeners: Set<(chunk: Buffer) => void> = new Set();
  private viewers: Set<ViewerHandle> = new Set();

  constructor(args: ConsoleSessionArgs) {
    this.localTaskId = args.localTaskId;
    this.connectionId = args.connectionId;
    this.ownerId = args.ownerId;
    this.startedByUserId = args.startedByUserId;
    this.startedAt = Date.now();
    this._lastActivityAt = this.startedAt;
    this.cols = args.cols;
    this.rows = args.rows;
    this.channel = args.channel;
    this.client = args.client;
    this.scrollback = new ByteRingBuffer(args.scrollbackCap);
    this.auditRepo = args.auditRepo;
    this.headless = new HeadlessTerminal({
      cols: args.cols,
      rows: args.rows,
      allowProposedApi: true,
      // We use writeSync (still supported, listed as "deprecated" by xterm)
      // to keep snapshotScreen() callable synchronously. Silence the
      // one-shot deprecation warning that would otherwise spam logs.
      logLevel: 'off',
    });

    this.channel.on('data', (data: Buffer) => this.handleOutput(data));
    this.channel.on('close', () => this.onTransportDown());

    // The Client (connection) can fail independently of the channel:
    // ssh2 re-emits transport-level socket errors (ECONNRESET on idle
    // timeout, network drop) as an 'error' on the Client. Without a
    // listener Node treats an 'error' event as fatal and kills the whole
    // process — this handler keeps a dropped connection to a single
    // session-scoped teardown. 'close' covers a graceful peer disconnect.
    this.client.on('error', (e: Error) => {
      logger.warn(
        `[console-session] client error task=${this.localTaskId}: ${(e as Error).message}`,
      );
      this.onTransportDown();
    });
    this.client.on('close', () => this.onTransportDown());
  }

  /** Tear the session down once when the underlying SSH transport drops. */
  private onTransportDown(): void {
    if (this.closing) return;
    this.close('host_disconnect').catch((e) =>
      logger.warn(`[console-session] close error: ${(e as Error).message}`),
    );
  }

  get lastActivityAt(): number {
    return this._lastActivityAt;
  }
  get totalInputBytes(): number {
    return this._totalInputBytes;
  }
  get totalOutputBytes(): number {
    return this._totalOutputBytes;
  }
  get isClosed(): boolean {
    return this.closed;
  }

  onOutput(listener: (chunk: Buffer) => void): () => void {
    this.outputListeners.add(listener);
    return () => {
      this.outputListeners.delete(listener);
    };
  }

  /**
   * Register a WebSocket viewer attached to this session. Each viewer carries
   * the acting userId and a close() closure that gracefully ends the WS.
   *
   * Returns an unsubscribe function the caller must invoke on ws 'close'.
   * Used by `SessionRegistry.revokeAccessFor` to kick specific viewers when
   * their grant is revoked, without tearing down the whole session.
   */
  addViewer(handle: ViewerHandle): () => void {
    this.viewers.add(handle);
    return () => {
      this.viewers.delete(handle);
    };
  }

  listViewers(): ViewerHandle[] {
    return [...this.viewers];
  }

  scrollbackBytes(): Buffer {
    return this.scrollback.concat();
  }

  write(buf: Buffer, source: InputSource): void {
    if (this.closed) return;
    this._lastActivityAt = Date.now();

    // Both human and AI inputs are forwarded byte-for-byte to the PTY,
    // so the shell's local echo is what drives what appears on screen —
    // same path xterm.js takes for human keystrokes. AI input also has
    // its LF terminators rewritten to CR because PTY cooked mode
    // (ICRNL) expects CR as Enter; browser xterm sends CR for Enter, so
    // this aligns AI and human flows. Without it, bash would see
    // "ls -la\n" as a single literal character and never execute.
    //
    // Deny-list enforcement on full lines happens upstream:
    //   - human input → checkConsoleInput in console-ws-api at line term
    //   - AI input    → checkConsoleInput in sendInput before reaching here
    // Partial input (no newline) is forwarded so the shell can echo each
    // character back, matching the live terminal experience the user
    // expects in either role.
    const out = source === 'ai' ? normalizeLfToCr(buf) : buf;
    this._totalInputBytes += out.length;
    const ok = this.channel.write(out);
    if (!ok) {
      // ssh2 channel signals back-pressure when the SSH send window
      // shrinks below the chunk size. The write is still buffered
      // internally and will be flushed on 'drain', but the input echo
      // from the shell will be delayed. Log so we can correlate user
      // reports of "freeze" with actual flow-control events.
      logger.warn(
        `[console-session] ${source} channel.write back-pressure task=${this.localTaskId} bytes=${out.length}`,
      );
    }
  }

  resize(cols: number, rows: number): void {
    if (this.closed) return;
    this.cols = cols;
    this.rows = rows;
    this.channel.setWindow(rows, cols, 0, 0);
    this.headless.resize(cols, rows);
  }

  snapshotScreen(): ScreenSnapshot {
    const buf = this.headless.buffer.active;
    const lines: string[] = [];
    for (let y = 0; y < this.rows; y++) {
      const line = buf.getLine(buf.viewportY + y);
      lines.push(line ? line.translateToString(true) : '');
    }
    return {
      cols: this.cols,
      rows: this.rows,
      text: lines.join('\n'),
      cursor: { x: buf.cursorX, y: buf.cursorY },
    };
  }

  snapshotScrollback(opts: { maxBytes: number }): ScrollbackSnapshot {
    const raw = this.scrollback.concat().toString('utf8');
    const stripped = stripAnsi(raw);
    if (stripped.length <= opts.maxBytes) {
      return { text: stripped, byteCount: stripped.length, truncated: false };
    }
    return {
      text: stripped.slice(stripped.length - opts.maxBytes),
      byteCount: stripped.length,
      truncated: true,
    };
  }

  async close(reason: SessionCloseReason): Promise<void> {
    if (this.closing) return;
    this.closing = true;
    this.closed = true;
    try {
      try {
        this.channel.end();
      } catch {
        /* already gone */
      }
      try {
        // End the owning Client too — channel.end() only closes the shell
        // channel, the TCP connection stays up until the client is ended.
        // Guarded by `closing` above so the resulting 'close' event is a
        // no-op in onTransportDown.
        this.client.end();
      } catch {
        /* already gone */
      }
      try {
        this.headless.dispose();
      } catch {
        /* idempotent */
      }
      this.auditRepo.beginAndComplete(
        {
          action: 'ssh.console.close',
          connectionId: this.connectionId,
          ownerId: this.ownerId,
          actingUserId: this.startedByUserId,
          detail: {
            reason,
            duration_ms: Date.now() - this.startedAt,
            total_input_bytes: this._totalInputBytes,
            total_output_bytes: this._totalOutputBytes,
          },
        },
        'success',
      );
    } finally {
      this.outputListeners.clear();
      this.viewers.clear();
    }
  }

  private handleOutput(data: Buffer): void {
    this._totalOutputBytes += data.length;
    this._lastActivityAt = Date.now();
    this.scrollback.append(data);
    this.writeToHeadlessSync(data);
    for (const l of this.outputListeners) {
      try {
        l(data);
      } catch (e) {
        logger.warn(`[console-session] listener error: ${(e as Error).message}`);
      }
    }
  }

  /**
   * Write data to the headless xterm so that buffer reads in the same tick
   * see it. The public Terminal.write() is async (parser runs via a
   * scheduler). We use _core._writeBuffer.writeSync() — an internal path
   * marked "deprecated" but in fact still the documented escape hatch for
   * server-side rendering. If a future xterm release removes it, the unit
   * test will fail and we'll need to make snapshotScreen() async.
   */
  private writeToHeadlessSync(data: Buffer): void {
    interface HeadlessInternals {
      _core?: {
        _writeBuffer?: {
          writeSync?: (data: Uint8Array | string) => void;
        };
      };
    }
    const internals = this.headless as unknown as HeadlessInternals;
    const ws = internals._core?._writeBuffer?.writeSync;
    if (typeof ws === 'function') {
      ws.call(internals._core!._writeBuffer, data);
    } else {
      this.headless.write(data);
    }
  }
}
