/**
 * src/http/auth.ts — optional shared access-token gate (w5-access-token).
 *
 * Pure, dependency-light helpers (mirrors the shape/discipline of
 * src/http/origin.ts). This layer is **ADDITIVE**: it sits *in front of* the
 * existing Origin/CSWSH defence (src/http/origin.ts) and CSRF guard
 * (`requireAllowedOrigin` in src/server.ts) — it never replaces or weakens them.
 *
 * When `WEBTERM_TOKEN` is unset/empty the whole gate is DISABLED — every helper
 * that consults `cfg.webtermToken` short-circuits and the server wiring calls
 * `next()` unconditionally, so behaviour is byte-identical to today (LAN
 * zero-config preserved). The gate activates only when a token is configured.
 *
 * ── HONEST SECURITY BOUNDARY (read before trusting this) ────────────────────
 * This is a **bar-raiser, NOT a TLS/Tailscale substitute.** On a bare-LAN
 * `ws://`/`http://` deployment the terminal stream — and therefore this cookie
 * and token — travel in CLEARTEXT. Anyone sniffing the LAN sees the token and
 * can REPLAY it (there is no per-request nonce, no channel binding). The token
 * only meaningfully hardens the relay/tunnel path, where the edge terminates
 * TLS and the browser speaks `wss://`/`https://`. It is a single shared secret:
 * no per-user identity, no revocation except changing the env var + restarting,
 * no lockout beyond rate-limiting. Do NOT read it as "safe on the public
 * internet" — the TECH_DOC §7 "never port-forward this raw" guidance stands.
 */

import { createHash, timingSafeEqual } from 'node:crypto'
import type { Config } from '../types.js'

/** The auth cookie name. HttpOnly (JS can't read it → XSS can't exfiltrate). */
export const AUTH_COOKIE_NAME = 'webterm_auth'

/** Cookie lifetime (seconds). Shipped as a constant (YAGNI — no env dial in v1);
 *  30 days balances "don't re-auth every session" against a bounded replay window. */
const COOKIE_TTL_SEC = 30 * 24 * 60 * 60 // 2592000 (30 days)

/** Minimal structural view of an incoming request — keeps this module free of
 *  Express/DOM types (a real `http.IncomingMessage`/Express `Request` is
 *  structurally assignable). Used only by `isHttpsRequest`. */
export interface RequestLike {
  readonly headers: Readonly<Record<string, string | string[] | undefined>>
  // `remoteAddress` is here only so a real `net.Socket` is structurally
  // assignable (TS weak-type check needs a shared property); `encrypted` (a
  // `tls.TLSSocket` field) is the one this module actually reads.
  readonly socket?: { readonly encrypted?: boolean; readonly remoteAddress?: string } | undefined
}

/** True iff auth is enabled: a non-empty token is configured. Unset/empty ⇒
 *  DISABLED (the single central switch — no scattered `if` checks elsewhere). */
export function isAuthEnabled(cfg: Pick<Config, 'webtermToken'>): boolean {
  return typeof cfg.webtermToken === 'string' && cfg.webtermToken.length > 0
}

/**
 * Parse a raw `Cookie:` header into a name→value map.
 * Malformed pairs (no `=`, empty name) are ignored; last write wins on dup names.
 * Values are returned verbatim (NOT URL-decoded) — our token uses a cookie-safe
 * charset (validated at config load) so there is nothing to decode.
 */
export function parseCookieHeader(header: string | undefined): Record<string, string> {
  const out: Record<string, string> = {}
  if (header === undefined || header === '') return out
  for (const part of header.split(';')) {
    const eq = part.indexOf('=')
    if (eq <= 0) continue // no '=' or empty name → ignore
    const name = part.slice(0, eq).trim()
    if (name === '') continue
    out[name] = part.slice(eq + 1).trim()
  }
  return out
}

/**
 * Constant-time string comparison (SECURITY-CRITICAL — never use `===`).
 *
 * Both inputs are hashed with SHA-256 to a FIXED 32 bytes, then compared with
 * `crypto.timingSafeEqual`. Hashing-to-fixed-length is the "fixed-length guard":
 * it removes the length side-channel (unequal-length secrets no longer leak
 * their length via timing) AND sidesteps `timingSafeEqual`'s throw-on-length-
 * mismatch. A present-vs-absent (empty/undefined) candidate short-circuits to
 * `false` before the comparator — a missing value is not a secret-compare oracle,
 * not a timing signal to protect.
 */
export function constantTimeEqual(a: string | undefined, b: string | undefined): boolean {
  // Boundary guard (input validation): never trust the candidate. An empty or
  // absent value can never be a match and must not reach the comparator.
  if (typeof a !== 'string' || typeof b !== 'string') return false
  if (a.length === 0 || b.length === 0) return false
  const ha = createHash('sha256').update(a, 'utf8').digest()
  const hb = createHash('sha256').update(b, 'utf8').digest()
  return timingSafeEqual(ha, hb) // both are exactly 32 bytes → never throws
}

/**
 * True iff the request's cookie carries a valid auth token.
 * Disabled config (no token) ⇒ `false` — callers MUST short-circuit on
 * `isAuthEnabled` first; a disabled gate never treats anyone as "authed".
 */
export function cookieIsAuthed(
  cfg: Pick<Config, 'webtermToken'>,
  cookieHeader: string | undefined,
): boolean {
  const token = cfg.webtermToken
  if (typeof token !== 'string' || token.length === 0) return false
  const presented = parseCookieHeader(cookieHeader)[AUTH_COOKIE_NAME]
  return constantTimeEqual(presented, token)
}

/**
 * Build the `Set-Cookie` header value for a successful auth.
 *
 * Flags: `HttpOnly` (XSS can't read it), `SameSite=Strict` (cross-site pages
 * can't ride the cookie — complements the Origin/CSWSH defence), `Path=/`,
 * `Max-Age=<ttl>`, and `Secure` **only when `opts.secure`**. The dynamic
 * `Secure` is required: a `Secure` cookie is never sent over `ws://`/`http://`,
 * so forcing it would silently break LAN-over-HTTP auth; over the relay
 * (`x-forwarded-proto: https`) it must be present.
 */
export function buildSetCookie(
  cfg: Pick<Config, 'webtermToken'>,
  opts: { secure: boolean },
): string {
  const parts = [
    `${AUTH_COOKIE_NAME}=${cfg.webtermToken ?? ''}`,
    'Path=/',
    `Max-Age=${COOKIE_TTL_SEC}`,
    'HttpOnly',
    'SameSite=Strict',
  ]
  if (opts.secure) parts.push('Secure')
  return parts.join('; ')
}

/**
 * True iff the request arrived over HTTPS/WSS — either directly
 * (`socket.encrypted`) or behind a TLS-terminating edge that set
 * `x-forwarded-proto: https` (the relay/tunnel path). Drives the dynamic
 * `Secure` cookie flag above.
 */
export function isHttpsRequest(req: RequestLike): boolean {
  const xfp = req.headers['x-forwarded-proto']
  const proto = Array.isArray(xfp) ? xfp[0] : xfp
  if (typeof proto === 'string' && proto.split(',')[0]?.trim().toLowerCase() === 'https') {
    return true
  }
  return req.socket?.encrypted === true
}
