/**
 * src/config.ts — T4 / T-config: environment config loader
 *
 * Reads env vars once at startup, validates them, derives allowedOrigins from
 * local NIC IPs (M1 — NOT from bindHost; 0.0.0.0 is never a valid Origin),
 * and returns a frozen Config object.
 *
 * v0.7 adds 21 new env vars for the Walk-away Workbench features (A1–A5, B1–B4).
 * The returned object is a superset of Config (all existing fields + 21 new v0.7
 * fields). Callers that use `const cfg = loadConfig(env)` (no explicit `: Config`
 * annotation) get the full type with all new fields accessible. Callers that
 * narrow to `Config` (e.g. function params typed as Config) still work because
 * the wider type is structurally assignable to Config.
 *
 * NOTE(T-config): The Config interface in src/types.ts should be extended with the
 * 21 new fields listed below so downstream tasks (T-server-wire, N-push, etc.) can
 * access them type-safely via the Config type. This is a coordination point for the
 * orchestrator — the impl is here, the interface declaration belongs in T-types.
 */

import os from 'node:os'
import path from 'node:path'
import type { Config, EnvLike, PermissionMode } from './types.js'
import { tmuxAvailable } from './session/tmux.js'

/** USE_TMUX: '1'/'true'/'on' → on, '0'/'false'/'off' → off, else auto-detect tmux. */
function resolveUseTmux(raw: string | undefined): boolean {
  const v = raw?.trim().toLowerCase()
  if (v === '1' || v === 'true' || v === 'on') return true
  if (v === '0' || v === 'false' || v === 'off') return false
  return tmuxAvailable() // unset / 'auto'
}

// ── constants ─────────────────────────────────────────────────────────────────

const DEFAULT_PORT = 3000
const DEFAULT_BIND_HOST = '0.0.0.0'
const DEFAULT_IDLE_TTL_SEC = 24 * 60 * 60 // 24 hours in seconds
const DEFAULT_SCROLLBACK_BYTES = 2 * 1024 * 1024 // 2 MB
const DEFAULT_MAX_PAYLOAD_BYTES = 1 * 1024 * 1024 // 1 MB
const DEFAULT_WS_PATH = '/term'
const DEFAULT_MAX_SESSIONS = 50
const DEFAULT_MAX_MSGS_PER_SEC = 2000
const DEFAULT_PERM_TIMEOUT_MS = 5 * 60_000 // H3: hold a PermissionRequest for 5 min
const DEFAULT_REAP_INTERVAL_MS = 60_000 // idle reaper sweeps every minute
const DEFAULT_PREVIEW_BYTES = 24 * 1024 // manage-page preview: tail of scrollback
const DEFAULT_PROJECT_SCAN_DEPTH = 4 // v0.6: how deep to scan roots for .git
const DEFAULT_PROJECT_SCAN_TTL_MS = 10_000 // v0.6: /projects discovery cache TTL
const DEFAULT_EDITOR_CMD = 'code' // v0.6: POST /open-in-editor launches this on the host

// v0.7 Walk-away Workbench defaults
// A1 push notifications
const DEFAULT_PUSH_MAX_SUBS = 50
// A4 timeline
const DEFAULT_TIMELINE_MAX = 200
// A5 stuck detection
const DEFAULT_STUCK_TTL_SEC = 600 // 10 minutes (env var in seconds)
// B1 git diff
const DEFAULT_DIFF_TIMEOUT_MS = 2_000
const DEFAULT_DIFF_MAX_BYTES = 2 * 1024 * 1024 // 2 MB
const DEFAULT_DIFF_MAX_FILES = 300
// W3 PR + CI status chip (gh) — larger timeout than diff: gh hits the network
const DEFAULT_GH_TIMEOUT_MS = 8_000
// B2 statusline telemetry
const DEFAULT_STATUSLINE_TTL_MS = 30_000
// B3 worktrees
const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000
// W5 fan-out board — max lanes one task may be fanned across (matches grid-6 capacity)
const DEFAULT_MAX_FANOUT_LANES = 6
// W4 git write (stage / commit / push)
const DEFAULT_GIT_OPS_TIMEOUT_MS = 10_000 // stage/commit exec bound (local, like worktree)
const DEFAULT_GIT_PUSH_TIMEOUT_MS = 120_000 // push is network-bound → longer bound
const DEFAULT_COMMIT_MSG_MAX_LEN = 5_000 // commit-message length cap
// W2 inject queue
const DEFAULT_QUEUE_MAX_ITEMS = 10
const DEFAULT_QUEUE_ITEM_MAX_BYTES = 4096
const DEFAULT_QUEUE_SETTLE_MS = 1500

/** Valid --permission-mode values (R0-confirmed). Whitelist for validation. */
const PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']

// ── helpers ───────────────────────────────────────────────────────────────────

/** Parse a non-negative integer env value (0 allowed), or the fallback when unset. */
function parseNonNegativeInt(
  raw: string | undefined,
  label: string,
  fallback: number,
): number {
  if (raw === undefined) return fallback
  const n = Number(raw)
  if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
    throw new Error(
      `Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative integer`,
    )
  }
  return n
}

/** Parse a non-negative float env value (0 allowed), or the fallback when unset. */
function parseNonNegativeFloat(
  raw: string | undefined,
  label: string,
  fallback: number,
): number {
  if (raw === undefined) return fallback
  const n = Number(raw)
  if (!Number.isFinite(n) || n < 0) {
    throw new Error(
      `Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative number`,
    )
  }
  return n
}

/** Parse a boolean env value ('1'/'true'/'on' → true, '0'/'false'/'off' → false), else fallback. */
function parseBool(raw: string | undefined, fallback: boolean): boolean {
  const v = raw?.trim().toLowerCase()
  if (v === undefined || v === '') return fallback
  if (v === '1' || v === 'true' || v === 'on') return true
  if (v === '0' || v === 'false' || v === 'off') return false
  return fallback
}

/** Parse a seconds-valued env var and return the equivalent milliseconds, or fallback (ms). */
function parseSecondsToMs(
  raw: string | undefined,
  label: string,
  fallbackMs: number,
): number {
  if (raw === undefined) return fallbackMs
  const n = Number(raw)
  if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
    throw new Error(
      `Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative integer (seconds)`,
    )
  }
  return n * 1000
}

/** Parse and validate a PermissionMode env var; throws for unknown values (including empty string). */
function parsePermissionMode(raw: string | undefined, label: string): PermissionMode {
  if (raw === undefined) return 'default'
  if ((PERMISSION_MODES as readonly string[]).includes(raw)) return raw as PermissionMode
  throw new Error(
    `Invalid config: ${label}=${JSON.stringify(raw)} — must be one of ${PERMISSION_MODES.join('|')}`,
  )
}

/** Parse PROJECT_ROOTS (comma-separated absolute paths); expand a leading '~' to homeDir.
 *  Empty / unset → [homeDir]. */
function parseProjectRoots(raw: string | undefined, homeDir: string): readonly string[] {
  if (raw === undefined || raw.trim() === '') return Object.freeze([homeDir])
  const roots = raw
    .split(',')
    .map((s) => s.trim())
    .filter((s) => s !== '')
    .map((s) => (s === '~' ? homeDir : s.startsWith('~/') ? homeDir + s.slice(1) : s))
  // Fail fast on relative roots: silently resolving them against the server's
  // CWD makes the scan target depend on where the process was launched (M1-style
  // "no surprising defaults"). Require absolute paths (after '~' expansion).
  for (const r of roots) {
    if (!path.isAbsolute(r)) {
      throw new Error(
        `Invalid config: PROJECT_ROOTS entry ${JSON.stringify(r)} — must be an absolute path (or '~' / '~/...').`,
      )
    }
  }
  return Object.freeze(roots.length > 0 ? roots : [homeDir])
}

/**
 * WEBTERM_TOKEN charset+length policy (w5-access-token). URL/cookie-safe chars
 * only (no `;`, space, `%`, or control chars) so the value cannot inject a
 * Set-Cookie/header split or create query-string `%`-encoding ambiguity; the
 * ≥16 floor multiplies brute-force cost against the /auth rate limiter.
 */
const WEBTERM_TOKEN_RE = /^[A-Za-z0-9._~+/=-]{16,512}$/

/**
 * Parse WEBTERM_TOKEN. Unset/empty ⇒ undefined (auth DISABLED, LAN zero-config
 * preserved). When present it must match WEBTERM_TOKEN_RE; invalid ⇒ throw
 * (fail-fast, like parsePort). The token value is NEVER included in the error
 * message (secret hygiene).
 */
function parseWebtermToken(raw: string | undefined): string | undefined {
  if (raw === undefined || raw === '') return undefined
  if (!WEBTERM_TOKEN_RE.test(raw)) {
    throw new Error(
      'Invalid config: WEBTERM_TOKEN must be 16–512 characters from the ' +
        'URL/cookie-safe set [A-Za-z0-9._~+/=-] (min length 16).',
    )
  }
  return raw
}

function parsePort(raw: string | undefined): number {
  if (raw === undefined) return DEFAULT_PORT
  const n = Number(raw)
  if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 65535) {
    throw new Error(
      `Invalid config: PORT=${JSON.stringify(raw)} — must be an integer 1–65535`,
    )
  }
  return n
}

function parseIdleTtl(raw: string | undefined): number {
  if (raw === undefined) return DEFAULT_IDLE_TTL_SEC * 1000
  const n = Number(raw)
  if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
    throw new Error(
      `Invalid config: IDLE_TTL=${JSON.stringify(raw)} — must be a non-negative integer (seconds)`,
    )
  }
  return n * 1000
}

/**
 * Derive allowedOrigins from host NIC IPs (M1).
 *
 * Rules:
 *  - Enumerate os.networkInterfaces(), pick IPv4 entries.
 *  - Skip internal (loopback) entries — 127.0.0.1 / localhost are added explicitly.
 *  - For every qualifying address, produce both http:// and https:// origins.
 *  - Append ALLOWED_ORIGINS env-var items (comma-separated).
 *  - Deduplicate.
 *  - Never include 0.0.0.0 (wildcard listen address, never a browser Origin).
 */
/** True iff `value` parses as a URL with an http: or https: scheme (M1). */
function isHttpOrigin(value: string): boolean {
  try {
    const u = new URL(value)
    return u.protocol === 'http:' || u.protocol === 'https:'
  } catch {
    return false
  }
}

function deriveAllowedOrigins(port: number, extraEnv: string | undefined): readonly string[] {
  const set = new Set<string>()

  // 1. Always include localhost / loopback
  const loopbackHosts = ['localhost', '127.0.0.1']
  for (const host of loopbackHosts) {
    set.add(`http://${host}:${port}`)
    set.add(`https://${host}:${port}`)
  }

  // 2. Enumerate NIC IPv4 addresses — skip internal (loopback already handled above)
  const ifaces = os.networkInterfaces()
  for (const ifaceList of Object.values(ifaces)) {
    if (!ifaceList) continue
    for (const entry of ifaceList) {
      // Only IPv4, non-internal entries; never 0.0.0.0
      if (
        entry.family === 'IPv4' &&
        !entry.internal &&
        entry.address !== '0.0.0.0'
      ) {
        set.add(`http://${entry.address}:${port}`)
        set.add(`https://${entry.address}:${port}`)
      }
    }
  }

  // 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings).
  //    Reject anything that is not a well-formed http(s) origin so an exotic
  //    scheme (file:, data:, javascript:) can't slip into the whitelist (M1).
  if (extraEnv) {
    for (const raw of extraEnv.split(',')) {
      const trimmed = raw.trim()
      if (trimmed === '') continue
      if (isHttpOrigin(trimmed)) set.add(trimmed)
    }
  }

  return Object.freeze([...set])
}

// ── loadConfig ────────────────────────────────────────────────────────────────

/**
 * Read and validate environment variables; fill in defaults for missing keys.
 * Invalid values (port out of range, TTL non-integer, etc.) throw immediately
 * so the server never starts in a misconfigured state (fail-fast).
 *
 * The returned object is Object.freeze'd — immutable after construction. It
 * contains all existing Config fields plus 21 new v0.7 Walk-away Workbench
 * fields. The return type is inferred (wider than Config) so callers can access
 * the new fields without explicit type assertions. Functions accepting Config
 * still work because the wider type is structurally assignable to Config.
 *
 * L5 (SEC-M4): permTimeoutMs must be > 0 — it is used to compute the
 * --max-time flag in setup-hooks.mjs (T-hooks-installer). Zero would make
 * --max-time=0 which disables the curl timeout entirely.
 */
export function loadConfig(env: EnvLike): Config {
  const port = parsePort(env['PORT'])

  const bindHost = env['BIND_HOST'] ?? DEFAULT_BIND_HOST

  const shellPath =
    env['SHELL_PATH'] ?? (process.env['SHELL'] as string | undefined) ?? '/bin/zsh'

  const homeDir = os.homedir()

  const idleTtlMs = parseIdleTtl(env['IDLE_TTL'])

  const scrollbackBytes = parseNonNegativeInt(
    env['SCROLLBACK_BYTES'],
    'SCROLLBACK_BYTES',
    DEFAULT_SCROLLBACK_BYTES,
  )

  const maxPayloadBytes = parseNonNegativeInt(
    env['MAX_PAYLOAD_BYTES'],
    'MAX_PAYLOAD_BYTES',
    DEFAULT_MAX_PAYLOAD_BYTES,
  )

  const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH

  const maxSessions = parseNonNegativeInt(env['MAX_SESSIONS'], 'MAX_SESSIONS', DEFAULT_MAX_SESSIONS)

  const maxMsgsPerSec = parseNonNegativeInt(
    env['MAX_MSGS_PER_SEC'],
    'MAX_MSGS_PER_SEC',
    DEFAULT_MAX_MSGS_PER_SEC,
  )

  const permTimeoutMs = parseNonNegativeInt(
    env['PERM_TIMEOUT_MS'],
    'PERM_TIMEOUT_MS',
    DEFAULT_PERM_TIMEOUT_MS,
  )

  // L5 / SEC-M4: permTimeoutMs must be > 0 (T-hooks-installer uses it for --max-time)
  if (permTimeoutMs === 0) {
    throw new Error(
      'Invalid config: PERM_TIMEOUT_MS must be > 0 (used to calculate --max-time for setup-hooks)',
    )
  }

  const reapIntervalMs = parseNonNegativeInt(
    env['REAP_INTERVAL_MS'],
    'REAP_INTERVAL_MS',
    DEFAULT_REAP_INTERVAL_MS,
  )

  const previewBytes = parseNonNegativeInt(env['PREVIEW_BYTES'], 'PREVIEW_BYTES', DEFAULT_PREVIEW_BYTES)

  const useTmux = resolveUseTmux(env['USE_TMUX'])

  const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])

  // w5-access-token — optional shared access token (SECRET — never logged here).
  // Unset/empty ⇒ undefined ⇒ auth disabled (LAN zero-config unchanged).
  const webtermToken = parseWebtermToken(env['WEBTERM_TOKEN'])

  // v0.6 Project Manager — discovery config
  const projectRoots = parseProjectRoots(env['PROJECT_ROOTS'], homeDir)
  const projectScanDepth = parseNonNegativeInt(
    env['PROJECT_SCAN_DEPTH'],
    'PROJECT_SCAN_DEPTH',
    DEFAULT_PROJECT_SCAN_DEPTH,
  )
  const projectScanTtlMs = parseNonNegativeInt(
    env['PROJECT_SCAN_TTL'],
    'PROJECT_SCAN_TTL',
    DEFAULT_PROJECT_SCAN_TTL_MS,
  )
  const projectDirtyCheck = parseBool(env['PROJECT_DIRTY_CHECK'], true)
  const editorCmd = env['EDITOR_CMD']?.trim() || DEFAULT_EDITOR_CMD

  // ── v0.7 Walk-away Workbench — 21 new env vars ─────────────────────────────

  // A1 push notifications (SEC-C5: vapidPrivateKey never logged by this module)
  const vapidPublicKey = env['VAPID_PUBLIC_KEY'] || undefined
  const vapidPrivateKey = env['VAPID_PRIVATE_KEY'] || undefined  // SECRET — never log
  const vapidSubject = env['VAPID_SUBJECT'] || undefined
  const pushStorePath =
    env['PUSH_STORE_PATH'] || path.join(homeDir, '.web-terminal-push-subs.json')
  const pushMaxSubs = parseNonNegativeInt(env['PUSH_MAX_SUBS'], 'PUSH_MAX_SUBS', DEFAULT_PUSH_MAX_SUBS)
  const prefsStorePath =
    env['PREFS_STORE_PATH'] || path.join(homeDir, '.web-terminal-prefs.json')
  const notifyDone = parseBool(env['NOTIFY_DONE'], true)  // default on
  const notifyDnd = parseBool(env['NOTIFY_DND'], false)   // default off (do not disturb)
  // DECISION_TOKEN_TTL_MS defaults to permTimeoutMs (parsed after it, order matters)
  const decisionTokenTtlMs = parseNonNegativeInt(
    env['DECISION_TOKEN_TTL_MS'],
    'DECISION_TOKEN_TTL_MS',
    permTimeoutMs,
  )

  // A4 activity timeline
  const timelineMax = parseNonNegativeInt(env['TIMELINE_MAX'], 'TIMELINE_MAX', DEFAULT_TIMELINE_MAX)
  const timelineEnabled = parseBool(env['TIMELINE_ENABLED'], true)

  // A5 stuck detection (STUCK_TTL env var is in seconds; stored in ms)
  const stuckTtlMs = parseSecondsToMs(env['STUCK_TTL'], 'STUCK_TTL', DEFAULT_STUCK_TTL_SEC * 1000)
  const stuckAlert = parseBool(env['STUCK_ALERT'], true)

  // B1 git diff viewer
  const diffTimeoutMs = parseNonNegativeInt(
    env['DIFF_TIMEOUT_MS'],
    'DIFF_TIMEOUT_MS',
    DEFAULT_DIFF_TIMEOUT_MS,
  )
  const diffMaxBytes = parseNonNegativeInt(
    env['DIFF_MAX_BYTES'],
    'DIFF_MAX_BYTES',
    DEFAULT_DIFF_MAX_BYTES,
  )
  const diffMaxFiles = parseNonNegativeInt(
    env['DIFF_MAX_FILES'],
    'DIFF_MAX_FILES',
    DEFAULT_DIFF_MAX_FILES,
  )

  // W3 PR + CI status chip (gh)
  const ghEnabled = parseBool(env['GH_ENABLED'], true)
  const ghTimeoutMs = parseNonNegativeInt(env['GH_TIMEOUT_MS'], 'GH_TIMEOUT_MS', DEFAULT_GH_TIMEOUT_MS)

  // B2 statusLine telemetry
  const statuslineTtlMs = parseNonNegativeInt(
    env['STATUSLINE_TTL_MS'],
    'STATUSLINE_TTL_MS',
    DEFAULT_STATUSLINE_TTL_MS,
  )

  // W3 quick-wins (b) cost budget guard — dollars, float ≥ 0; 0/unset = disabled.
  const costBudgetUsd = parseNonNegativeFloat(env['COST_BUDGET_USD'], 'COST_BUDGET_USD', 0)

  // B3 git worktree creation
  const worktreeEnabled = parseBool(env['WORKTREE_ENABLED'], true)
  const worktreeRoot = env['WORKTREE_ROOT'] || undefined  // undefined → computed at creation time
  const worktreeTimeoutMs = parseNonNegativeInt(
    env['WORKTREE_TIMEOUT_MS'],
    'WORKTREE_TIMEOUT_MS',
    DEFAULT_WORKTREE_TIMEOUT_MS,
  )

  // W5 fan-out board — cap on lanes one task may be fanned across (DoS guard,
  // like MAX_SESSIONS). 0 is allowed (disables fan-out) via parseNonNegativeInt.
  const maxFanoutLanes = parseNonNegativeInt(
    env['MAX_FANOUT_LANES'],
    'MAX_FANOUT_LANES',
    DEFAULT_MAX_FANOUT_LANES,
  )

  // W4 git write (stage / commit / push) — the highest-risk write channel
  const gitOpsEnabled = parseBool(env['GIT_OPS_ENABLED'], true) // master kill-switch
  const gitOpsTimeoutMs = parseNonNegativeInt(
    env['GIT_OPS_TIMEOUT_MS'],
    'GIT_OPS_TIMEOUT_MS',
    DEFAULT_GIT_OPS_TIMEOUT_MS,
  )
  const gitPushTimeoutMs = parseNonNegativeInt(
    env['GIT_PUSH_TIMEOUT_MS'],
    'GIT_PUSH_TIMEOUT_MS',
    DEFAULT_GIT_PUSH_TIMEOUT_MS,
  )
  const commitMsgMaxLen = parseNonNegativeInt(
    env['COMMIT_MSG_MAX_LEN'],
    'COMMIT_MSG_MAX_LEN',
    DEFAULT_COMMIT_MSG_MAX_LEN,
  )

  // B4 permission mode relay
  const defaultPermissionMode = parsePermissionMode(
    env['DEFAULT_PERMISSION_MODE'],
    'DEFAULT_PERMISSION_MODE',
  )
  const allowAutoMode = parseBool(env['ALLOW_AUTO_MODE'], false)  // default off (SEC-M5)

  // W2 server-side PTY-inject follow-up queue
  const queueEnabled = parseBool(env['QUEUE_ENABLED'], true)
  const queueMaxItems = parseNonNegativeInt(env['QUEUE_MAX_ITEMS'], 'QUEUE_MAX_ITEMS', DEFAULT_QUEUE_MAX_ITEMS)
  const queueItemMaxBytes = parseNonNegativeInt(
    env['QUEUE_ITEM_MAX_BYTES'],
    'QUEUE_ITEM_MAX_BYTES',
    DEFAULT_QUEUE_ITEM_MAX_BYTES,
  )
  const queueSettleMs = parseNonNegativeInt(env['QUEUE_SETTLE_MS'], 'QUEUE_SETTLE_MS', DEFAULT_QUEUE_SETTLE_MS)

  // ── assemble + freeze ───────────────────────────────────────────────────────
  // `satisfies Config` on the base object verifies all existing Config fields
  // are present without triggering excess-property checks on the v0.7 additions.
  const base = {
    port,
    bindHost,
    shellPath,
    homeDir,
    idleTtlMs,
    scrollbackBytes,
    maxPayloadBytes,
    wsPath,
    maxSessions,
    maxMsgsPerSec,
    permTimeoutMs,
    reapIntervalMs,
    previewBytes,
    useTmux,
    allowedOrigins,
    webtermToken,
    projectRoots,
    projectScanDepth,
    projectScanTtlMs,
    projectDirtyCheck,
    editorCmd,
  }

  return Object.freeze({
    ...base,
    // v0.7 additions (21 new fields)
    vapidPublicKey,
    vapidPrivateKey,
    vapidSubject,
    pushStorePath,
    pushMaxSubs,
    prefsStorePath,
    notifyDone,
    notifyDnd,
    decisionTokenTtlMs,
    timelineMax,
    timelineEnabled,
    stuckTtlMs,
    stuckAlert,
    diffTimeoutMs,
    diffMaxBytes,
    diffMaxFiles,
    ghEnabled,
    ghTimeoutMs,
    statuslineTtlMs,
    costBudgetUsd,
    worktreeEnabled,
    worktreeRoot,
    worktreeTimeoutMs,
    // W5 fan-out board
    maxFanoutLanes,
    // W4 git write (stage / commit / push)
    gitOpsEnabled,
    gitOpsTimeoutMs,
    gitPushTimeoutMs,
    commitMsgMaxLen,
    defaultPermissionMode,
    allowAutoMode,
    // W2 inject queue
    queueEnabled,
    queueMaxItems,
    queueItemMaxBytes,
    queueSettleMs,
  })
}
