/**
 * Service install dispatcher — PLAN_RELAY_AGENT T17, re-targeted for the native tunnel
 * (PLAN_TUNNEL_AUTOMATION B5). Detects the platform and emits TWO durable units — the base-app and
 * the agent — then loads/enables both. REFUSES to install as root (EXPLORE §4d least privilege).
 * All IO is injected so the logic is unit-testable without touching the real system.
 *
 * Two safety controls are load-bearing here:
 *   - FIX C-host-1 (S-GATE, CRITICAL): the base-app unit MUST bind loopback. A non-loopback
 *     `BIND_HOST` (e.g. `0.0.0.0`) is REJECTED — the throw happens before any file is written, so a
 *     rejected install emits nothing. An absent value is normalized to `127.0.0.1`.
 *   - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
 *     base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
 */
import { dirname, join } from 'node:path'
import type { AgentConfig } from '../config/agentConfig.js'
import {
  agentLabel,
  baseAppLabel,
  buildLaunchdPlist,
  launchdLoadCommand,
  launchdPlistPath,
  launchdUnloadCommand,
  type ServiceEnv,
} from './launchd.js'
import {
  agentUnitName,
  baseAppUnitName,
  buildSystemdUnit,
  systemdDisableCommand,
  systemdEnableCommand,
  systemdUnitPath,
  type SystemdUnitOptions,
} from './systemd.js'
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'

export type ServicePlatform = 'launchd' | 'systemd'

/**
 * Per-host packaging inputs threaded to the unit writers (PLAN_NATIVE_TUNNEL S2). All optional so
 * existing callers (and relay callers) are unaffected. `env` is the BASE-APP env (routed to the
 * base-app unit only); `baseAppExec` overrides the base-app `ExecStart` argv.
 */
export interface InstallOptions {
  /** Base-app env injected into the base-app unit (BIND_HOST, ALLOWED_ORIGINS, PORT, …). */
  readonly env?: ServiceEnv
  /** systemd `EnvironmentFile=` path (preferred over inline for values best kept off the unit). */
  readonly envFile?: string
  /** DNS zone label for the tunnel origin (`terminal` for native-tunnel hosts, default `term`). */
  readonly zone?: string
  /** Parent domain (e.g. `yaojia.wang`); with `cfg.subdomain` derives the tunnel ALLOWED_ORIGINS. */
  readonly domain?: string
  /** Base-app process argv (default `['node','dist/server.js']`). */
  readonly baseAppExec?: readonly string[]
}

/** S0 base-app env vars the install CLI bakes into the base-app unit, passed through verbatim. */
const BASE_APP_ENV_KEYS = [
  'ALLOWED_ORIGINS',
  'PORT',
  'SHELL_PATH',
  'IDLE_TTL',
  'USE_TMUX',
  'SCROLLBACK_BYTES',
  'MAX_PAYLOAD_BYTES',
] as const

/**
 * Tunnel hosts MUST bind loopback. At the relay the device-cert mTLS is the ONLY auth gate, so a
 * default `0.0.0.0` bind (`src/config.ts`) would serve an unauth'd shell on the LAN, bypassing mTLS
 * entirely (PLAN_NATIVE_TUNNEL S0/R2, FIX C-host-1). This is the normalized loopback default.
 */
export const TUNNEL_DEFAULT_BIND_HOST = '127.0.0.1'

/** Native-tunnel origin zone → `https://<subdomain>.terminal.<domain>`; overridable via TUNNEL_ZONE. */
export const TUNNEL_ORIGIN_ZONE = 'terminal'

/** Native-tunnel origin zone label; native installs MUST use this (FIX L-host-zone). */
export const NATIVE_ORIGIN_ZONE = 'terminal'

/** Base-app process argv when the caller does not override it. */
const DEFAULT_BASE_APP_EXEC: readonly string[] = ['node', 'dist/server.js']

/** A base-app BIND_HOST that is not loopback — the S-GATE fail-closed error (FIX C-host-1). */
export class BindHostError extends Error {
  constructor(value: string) {
    super(
      `refusing to install: BIND_HOST="${value}" is not loopback. A tunnel host MUST bind ` +
        '127.0.0.1/::1/localhost — the device-cert mTLS at the relay is the only auth gate, so a ' +
        '0.0.0.0 (or LAN-IP) bind would serve an unauth\'d shell on the LAN. [FIX C-host-1 S-GATE]',
    )
    this.name = 'BindHostError'
  }
}

/**
 * True iff `value` is a loopback bind address (a well-formed 127.0.0.0/8 IPv4 literal, ::1, or
 * localhost). Delegates to the shared strict check so a suffixed hostname such as
 * `127.0.0.1.attacker.example.com` — which Node would DNS-resolve before bind() — is REJECTED,
 * not treated as loopback (FIX C-host-1 S-GATE).
 */
function isLoopbackBindHost(value: string): boolean {
  return isLoopbackHostLiteral(value)
}

/**
 * S-GATE (FIX C-host-1): normalize an absent/empty BIND_HOST to loopback; REJECT any non-loopback
 * value (throws `BindHostError`). The emitted base-app unit can therefore never bind `0.0.0.0`.
 */
export function normalizeBindHost(value: string | undefined): string {
  if (value === undefined || value.length === 0) return TUNNEL_DEFAULT_BIND_HOST
  if (!isLoopbackBindHost(value)) throw new BindHostError(value)
  return value
}

/** Assert a native-tunnel install uses the `terminal` zone (FIX L-host-zone). Throws otherwise. */
export function assertNativeZone(zone: string | undefined): void {
  if (zone !== NATIVE_ORIGIN_ZONE) {
    throw new Error(
      `native tunnel install requires zone="${NATIVE_ORIGIN_ZONE}" (got "${zone ?? '(default term)'}")` +
        ' — the base-app origin must be https://<sub>.terminal.<domain> [FIX L-host-zone]',
    )
  }
}

/**
 * Resolve the per-host `InstallOptions` from the process environment (PLAN_NATIVE_TUNNEL S0/S2).
 * Sources the S0 base-app env — normalizing/gating `BIND_HOST` to loopback (S-GATE: throws on a
 * non-loopback value so no install can ever emit a LAN-exposed unit) — plus the tunnel-origin
 * `domain`/`zone` used to derive ALLOWED_ORIGINS. Pure/immutable (env is a parameter).
 */
export function buildInstallOptions(env: NodeJS.ProcessEnv): InstallOptions {
  const passthrough = Object.fromEntries(
    BASE_APP_ENV_KEYS.map((key) => [key, env[key]] as [string, string | undefined]).filter(
      (entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0,
    ),
  )
  // S-GATE at env-read time: a non-loopback BIND_HOST fails closed here (before any install).
  const serviceEnv: ServiceEnv = { BIND_HOST: normalizeBindHost(env.BIND_HOST), ...passthrough }
  const domain = env.TUNNEL_DOMAIN
  const envFile = env.AGENT_ENV_FILE
  const baseAppEntry = env.BASE_APP_ENTRY
  return {
    env: serviceEnv,
    ...(domain ? { domain, zone: env.TUNNEL_ZONE || TUNNEL_ORIGIN_ZONE } : {}),
    ...(envFile ? { envFile } : {}),
    ...(baseAppEntry ? { baseAppExec: ['node', baseAppEntry] } : {}),
  }
}

export class RootRefusedError extends Error {
  constructor() {
    super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
    this.name = 'RootRefusedError'
  }
}

export interface InstallDeps {
  writeFile(path: string, content: string): void
  runCommand(cmd: string, args: readonly string[]): Promise<void>
  getuid(): number
  homedir(): string
  username(): string
  binPath(): string
}

/** Map a Node platform to its service manager, or null if unsupported. */
export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
  if (os === 'darwin') return 'launchd'
  if (os === 'linux') return 'systemd'
  return null
}

/**
 * Resolve the BASE-APP env injected into the base-app unit. Runs the S-GATE on `BIND_HOST` (throws
 * `BindHostError` on a non-loopback value, before any write) and, when a `domain` (+ `cfg.subdomain`)
 * is supplied, merges the tunnel origin `https://<subdomain>.<zone>.<domain>` into ALLOWED_ORIGINS —
 * never weakening an origin the caller already provided. Immutable.
 */
function resolveBaseAppEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
  const base = options.env ?? {}
  const bindHost = normalizeBindHost(base.BIND_HOST) // S-GATE — throws on non-loopback
  const withBind: ServiceEnv = { ...base, BIND_HOST: bindHost }
  if (!options.domain || !cfg.subdomain) return withBind
  const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
  return { ...withBind, ALLOWED_ORIGINS: mergeOrigins(withBind.ALLOWED_ORIGINS, origin) }
}

/**
 * Write + load BOTH service units for `platform`: the base-app (`node dist/server.js` + base-app
 * env) and the agent (`<bin> run`, supervises frpc — no base-app env). Throws RootRefusedError if
 * running as root, or BindHostError (S-GATE) BEFORE any write if the base-app BIND_HOST is not
 * loopback (so a rejected install emits nothing).
 */
export async function installService(
  cfg: AgentConfig,
  platform: ServicePlatform,
  deps: InstallDeps,
  options: InstallOptions = {},
): Promise<void> {
  if (deps.getuid() === 0) throw new RootRefusedError()
  // Resolve (and S-GATE) the base-app env BEFORE any IO — a non-loopback BIND_HOST throws here,
  // so nothing is ever written for a rejected install.
  const baseAppEnv = resolveBaseAppEnv(cfg, options)
  const bin = deps.binPath()
  const rawExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
  // launchd/systemd start with a minimal PATH that excludes /usr/local/bin (where a nvm/brew `node`
  // symlink usually lives), so a bare `node` program dies with EX_CONFIG(78). Use the absolute node
  // (process.execPath) and export a PATH so the units — and the base app's tmux/node-pty/frpc
  // subprocesses — resolve their tools.
  const nodePath = process.execPath
  const unitPath = `${dirname(nodePath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`
  const baseAppExec = rawExec[0] === 'node' ? [nodePath, ...rawExec.slice(1)] : rawExec
  const baseAppEnvWithPath = { ...baseAppEnv, PATH: unitPath }
  // The agent unit runs `run`, which loadConfig()-validates ENROLL_URL(https)/RELAY_URL(wss) up front
  // (fail-fast). Without these in the unit env the supervisor exits 1 on every launch — so inject the
  // agent's own runtime config (NOT the base-app env) alongside PATH.
  const agentEnv: Record<string, string> = {
    PATH: unitPath,
    ENROLL_URL: cfg.enrollUrl,
    RELAY_URL: cfg.relayUrl,
    STATE_DIR: cfg.stateDir,
    LOCAL_TARGET_URL: cfg.localTargetUrl,
  }

  if (platform === 'launchd') {
    const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
    deps.writeFile(
      baseAppPath,
      buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel(), join(cfg.stateDir, 'base-app.log')),
    )
    const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
    deps.writeFile(
      agentPath,
      buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel(), join(cfg.stateDir, 'agent.log')),
    )
    for (const path of [baseAppPath, agentPath]) {
      const { cmd, args } = launchdLoadCommand(path)
      await deps.runCommand(cmd, args)
    }
    return
  }

  const baseAppOptions: SystemdUnitOptions = options.envFile
    ? { env: baseAppEnvWithPath, envFile: options.envFile }
    : { env: baseAppEnvWithPath }
  const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
  deps.writeFile(
    baseAppPath,
    buildSystemdUnit(baseAppExec.join(' '), deps.username(), baseAppOptions, 'web-terminal base app (loopback)'),
  )
  const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
  deps.writeFile(
    agentPath,
    buildSystemdUnit(`${nodePath} ${bin} run`, deps.username(), { env: agentEnv }, 'web-terminal host agent (frpc supervisor)'),
  )
  for (const unit of [baseAppUnitName(), agentUnitName()]) {
    const { cmd, args } = systemdEnableCommand(unit)
    await deps.runCommand(cmd, args)
  }
}

/** Unload/disable BOTH service units for `platform` (agent first, then base-app). */
export async function uninstallService(
  platform: ServicePlatform,
  deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
): Promise<void> {
  if (platform === 'launchd') {
    for (const label of [agentLabel(), baseAppLabel()]) {
      const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label))
      await deps.runCommand(cmd, args)
    }
    return
  }
  for (const unit of [agentUnitName(), baseAppUnitName()]) {
    const { cmd, args } = systemdDisableCommand(unit)
    await deps.runCommand(cmd, args)
  }
}
