/**
 * src/protocol.ts — WebSocket message codec + validation (T5, pure functions)
 *
 * Invariant #3 (ARCHITECTURE §8): parseClientMessage NEVER throws.
 * All invalid input flows through ParseResult.ok=false.
 *
 * Imports only from src/types.ts (frozen shared contract, T2).
 */

import type { ClientMessage, ServerMessage, ParseResult } from './types.js'

// ─── SESSION_ID_RE (M7) ───────────────────────────────────────────────────────

/**
 * UUID v4 pattern.
 *   - 8-4-4-4-12 hex groups
 *   - version nibble must be '4'
 *   - variant nibble must be 8, 9, a, or b  (10xx in binary)
 *
 * M7: strings like "abc123" must be rejected; crypto.randomUUID() values must pass.
 */
export const SESSION_ID_RE =
  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i

// ─── Type whitelist ───────────────────────────────────────────────────────────

const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize', 'approve', 'reject'])

// ─── parseClientMessage ───────────────────────────────────────────────────────

/**
 * Parse and validate a raw WS text frame as a ClientMessage.
 *
 * Steps (ARCHITECTURE §3.2 / TECH_DOC §5.3):
 *  1. JSON.parse — bad JSON → ok=false
 *  2. Must be a plain object (not null, array, etc.)
 *  3. type must be in whitelist {'attach','input','resize'}
 *  4. Per-type field validation:
 *     - resize: cols and rows are integers in [1, 1000]
 *     - input:  data is a string (passed through verbatim, invariant #9)
 *     - attach: sessionId is null OR a string matching SESSION_ID_RE (M7)
 *
 * NEVER throws — all errors return { ok: false, error: <message> }.
 */
export function parseClientMessage(raw: string): ParseResult {
  // Step 1: JSON parse
  let parsed: unknown
  try {
    parsed = JSON.parse(raw)
  } catch {
    return { ok: false, error: 'invalid JSON' }
  }

  // Step 2: Must be a plain object
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
    return { ok: false, error: 'message must be a JSON object' }
  }

  const obj = parsed as Record<string, unknown>

  // Step 3: type whitelist
  const type = obj['type']
  if (typeof type !== 'string' || !ALLOWED_TYPES.has(type)) {
    return { ok: false, error: `unknown or missing type: ${String(type)}` }
  }

  // Step 4: per-type validation
  if (type === 'resize') {
    return validateResize(obj)
  }

  if (type === 'input') {
    return validateInput(obj)
  }

  // approve/reject (H3) carry no payload.
  if (type === 'approve') {
    return { ok: true, message: { type: 'approve' } }
  }
  if (type === 'reject') {
    return { ok: true, message: { type: 'reject' } }
  }

  // type === 'attach'
  return validateAttach(obj)
}

// ─── Per-type validators ──────────────────────────────────────────────────────

function validateResize(obj: Record<string, unknown>): ParseResult {
  const { cols, rows } = obj

  if (!isValidDimension(cols)) {
    return {
      ok: false,
      error: `resize.cols must be an integer in [1, 1000], got ${String(cols)}`,
    }
  }

  if (!isValidDimension(rows)) {
    return {
      ok: false,
      error: `resize.rows must be an integer in [1, 1000], got ${String(rows)}`,
    }
  }

  return {
    ok: true,
    message: { type: 'resize', cols: cols as number, rows: rows as number },
  }
}

function isValidDimension(value: unknown): value is number {
  return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 1000
}

function validateInput(obj: Record<string, unknown>): ParseResult {
  const { data } = obj

  if (typeof data !== 'string') {
    return { ok: false, error: `input.data must be a string, got ${typeof data}` }
  }

  // Invariant #9: data passed through verbatim, no content filtering
  return { ok: true, message: { type: 'input', data } }
}

function validateAttach(obj: Record<string, unknown>): ParseResult {
  const { sessionId } = obj

  // sessionId must be explicitly present as null or a UUID v4 string (M7)
  if (!Object.prototype.hasOwnProperty.call(obj, 'sessionId')) {
    return { ok: false, error: 'attach.sessionId field is required' }
  }

  // Optional cwd (M6): must be an absolute path string if present.
  // NOTE (Sec L2): the only check here is a leading '/'. Any further path
  // normalisation (e.g. path.resolve) stays on the backend (session.ts), because
  // this module is shared with the browser bundle and must NOT import node:path.
  // Within the current threat model this is informational — the caller already
  // has a full shell, so a crafted cwd grants nothing beyond what they can type.
  const rawCwd = obj['cwd']
  let cwd: string | undefined
  if (rawCwd !== undefined) {
    if (typeof rawCwd !== 'string' || !rawCwd.startsWith('/')) {
      return { ok: false, error: 'attach.cwd must be an absolute path string' }
    }
    cwd = rawCwd
  }

  if (sessionId === null) {
    return {
      ok: true,
      message: cwd !== undefined ? { type: 'attach', sessionId: null, cwd } : { type: 'attach', sessionId: null },
    }
  }

  if (typeof sessionId !== 'string') {
    return {
      ok: false,
      error: `attach.sessionId must be null or a UUID v4 string, got ${typeof sessionId}`,
    }
  }

  if (!SESSION_ID_RE.test(sessionId)) {
    return {
      ok: false,
      error: `attach.sessionId is not a valid UUID v4: ${sessionId}`,
    }
  }

  return {
    ok: true,
    message: cwd !== undefined ? { type: 'attach', sessionId, cwd } : { type: 'attach', sessionId },
  }
}

// ─── serialize ────────────────────────────────────────────────────────────────

/**
 * Serialize a ServerMessage to a JSON text frame.
 *
 * Internal trusted data — JSON.stringify directly (ARCHITECTURE §3.2).
 */
export function serialize(msg: ServerMessage): string {
  return JSON.stringify(msg)
}
