/**
 * Pinned frpc binary provisioner — TASK B3 (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
 *
 * Mirrors the `dist/buildBinary.ts` verify-download discipline: detect OS/arch, select a PINNED
 * release `{ version, url, sha256 }`, download the artifact TO DISK (a temp file), verify its
 * SHA-256 against the pin BEFORE the binary is placed or executed, then place it atomically
 * (temp -> rename) with the exec bit. On hash mismatch NOTHING is placed and the temp is removed.
 *
 * Non-negotiable (§5): never `curl | sh` a streamed secret, never disable TLS verification (no `-k`)
 * — the default fetch enforces `https:` and there is no insecure escape hatch.
 *
 * frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
 * a host cannot exec a `.tar.gz`. So AFTER the archive hash matches its pin (and only then) the bytes
 * are handed to the path-traversal-hardened extractor in `untar.ts`, which pulls out ONLY the inner
 * `frpc`; that verified binary is what gets placed. Extraction failure (no `frpc`, corrupt gzip/tar,
 * unsafe entry name, oversize) places nothing and throws `FrpcProvisionError`.
 *
 * The network fetch and the filesystem are INJECTABLE seams (`ProvisionFrpcDeps`) so tests exercise
 * URL/arch selection, hash-match extraction+placement, hash-mismatch rejection, extraction failures,
 * and unsupported-platform errors with no network access.
 */
import { createHash, randomBytes } from 'node:crypto'
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { extractFrpcBinary } from './untar.js'

export type FrpcPlatform = 'darwin-arm64' | 'darwin-amd64' | 'linux-arm64' | 'linux-amd64'

export const FRPC_PLATFORMS: readonly FrpcPlatform[] = [
  'darwin-arm64',
  'darwin-amd64',
  'linux-arm64',
  'linux-amd64',
]

/** A pinned frpc release artifact for one platform. `sha256` is lowercase hex over the artifact. */
export interface FrpcReleaseRef {
  readonly version: string
  readonly url: string
  readonly sha256: string
}

const FRPC_VERSION = '0.61.1'
const RELEASE_BASE = `https://github.com/fatedier/frp/releases/download/v${FRPC_VERSION}`

/**
 * The pinned release map. URLs point at the real frp v0.61.1 assets and the `sha256` fields are the
 * REAL published digests from `frp_sha256_checksums.txt` (verified against the downloaded
 * darwin_arm64 archive on 2026-07-09). To bump frp: update `FRPC_VERSION` and paste the new digests
 * from that release's checksum file. Tests inject their own release map.
 */
export const FRPC_RELEASES: Readonly<Record<FrpcPlatform, FrpcReleaseRef>> = {
  'darwin-arm64': {
    version: FRPC_VERSION,
    url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_arm64.tar.gz`,
    sha256: '3e65f13a17a284bd6013e6bb6856bc2720074cea6094cc446c1f4c3932154c2d',
  },
  'darwin-amd64': {
    version: FRPC_VERSION,
    url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_amd64.tar.gz`,
    sha256: '403a0ee5e92f083a863d984b7af1e9d70ba2aaa28e87f42f1fe085adf76b8491',
  },
  'linux-arm64': {
    version: FRPC_VERSION,
    url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_arm64.tar.gz`,
    sha256: 'af6366f2b43920ebfe6235dba6060770399ed1fb18601e5818552bd46a7621f8',
  },
  'linux-amd64': {
    version: FRPC_VERSION,
    url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_amd64.tar.gz`,
    sha256: 'bff260b68ca7b1461182a46c4f34e9709ba32764eed30a15dd94ac97f50a2c40',
  },
}

/** Node `os.arch()` values mapped to frp's arch tokens. */
const ARCH_MAP: Readonly<Record<string, 'arm64' | 'amd64'>> = {
  arm64: 'arm64',
  x64: 'amd64',
}
const SUPPORTED_OS: ReadonlySet<string> = new Set(['darwin', 'linux'])

const BIN_NAME = 'frpc'
const TMP_NAME = 'frpc.download.tmp'
const EXEC_MODE = 0o755
const DIR_MODE = 0o700

/** Map `os.platform()` + `os.arch()` to a supported `FrpcPlatform`, or `null` if unsupported. */
export function detectFrpcPlatform(platform: string, arch: string): FrpcPlatform | null {
  const mappedArch = ARCH_MAP[arch]
  if (!SUPPORTED_OS.has(platform) || mappedArch === undefined) return null
  const key = `${platform}-${mappedArch}` as FrpcPlatform
  return FRPC_PLATFORMS.includes(key) ? key : null
}

/** Injectable network fetch: returns the artifact bytes for `url`. */
export type FrpcFetch = (url: string) => Promise<Uint8Array>

/** Injectable filesystem seam (all async, mirrors `node:fs/promises`). */
export interface FrpcFsDeps {
  mkdir(dir: string): Promise<void>
  writeFile(path: string, data: Uint8Array): Promise<void>
  rename(from: string, to: string): Promise<void>
  chmod(path: string, mode: number): Promise<void>
  rm(path: string): Promise<void>
}

export interface ProvisionFrpcDeps {
  readonly fetch: FrpcFetch
  readonly fs: FrpcFsDeps
  /** Override the digest function (default: node:crypto SHA-256, lowercase hex). */
  readonly computeSha256?: (data: Uint8Array) => string
}

export interface ProvisionFrpcOptions {
  /** `os.platform()` (e.g. `'darwin'`, `'linux'`). */
  readonly platform: string
  /** `os.arch()` (e.g. `'arm64'`, `'x64'`). */
  readonly arch: string
  /** Directory the verified `frpc` binary is placed into. */
  readonly binDir: string
  /** Release map to select from; defaults to the pinned `FRPC_RELEASES`. */
  readonly releases?: Readonly<Record<FrpcPlatform, FrpcReleaseRef>>
}

export interface ProvisionResult {
  readonly binPath: string
  readonly version: string
  readonly platform: FrpcPlatform
}

/** A verify-download failure (unsupported platform, fetch error, or integrity mismatch). */
export class FrpcProvisionError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'FrpcProvisionError'
  }
}

function defaultSha256(data: Uint8Array): string {
  return createHash('sha256').update(data).digest('hex')
}

/** Default HTTPS fetch — enforces `https:` (never `-k`, never plain http) and a 2xx status. */
async function defaultFetch(url: string): Promise<Uint8Array> {
  let parsed: URL
  try {
    parsed = new URL(url)
  } catch {
    throw new FrpcProvisionError(`invalid frpc download URL: ${url}`)
  }
  if (parsed.protocol !== 'https:') {
    throw new FrpcProvisionError(`frpc download refuses non-https URL: ${url}`)
  }
  let res: Response
  try {
    res = await fetch(url)
  } catch (err: unknown) {
    // Surface transport failures (DNS, refused, TLS) through the SAME typed error as every other
    // failure path, so callers (e.g. the autoupdate rollback path) can pattern-match uniformly.
    throw new FrpcProvisionError(
      `frpc download failed: ${err instanceof Error ? err.message : 'network error'} for ${url}`,
    )
  }
  if (!res.ok) {
    throw new FrpcProvisionError(`frpc download failed: HTTP ${res.status} for ${url}`)
  }
  return new Uint8Array(await res.arrayBuffer())
}

const defaultFsDeps: FrpcFsDeps = {
  mkdir: async (dir) => {
    await mkdir(dir, { recursive: true, mode: DIR_MODE })
  },
  writeFile: async (path, data) => {
    await writeFile(path, data, { mode: 0o600 })
  },
  rename: async (from, to) => {
    await rename(from, to)
  },
  chmod: async (path, mode) => {
    await chmod(path, mode)
  },
  rm: async (path) => {
    await rm(path, { force: true })
  },
}

function defaultDeps(): ProvisionFrpcDeps {
  return { fetch: defaultFetch, fs: defaultFsDeps, computeSha256: defaultSha256 }
}

/**
 * Download + verify + extract + place the pinned frpc binary for the current platform.
 *
 * Order (verify-before-extract-before-exec): detect platform -> select pin -> fetch archive ->
 * write the unverified archive to a per-invocation temp -> SHA-256 verify against the pin. On
 * mismatch: remove the temp and throw (no extraction, nothing placed). On match: gunzip+untar to
 * pull ONLY the inner `frpc` (path-traversal-safe), overwrite the temp with that verified binary,
 * chmod exec, and atomic-rename into place. Any extraction failure removes the temp and throws.
 * Nothing is ever placed or made executable before the digest matches the pin.
 */
export async function provisionFrpc(
  opts: ProvisionFrpcOptions,
  deps: ProvisionFrpcDeps = defaultDeps(),
): Promise<ProvisionResult> {
  const platform = detectFrpcPlatform(opts.platform, opts.arch)
  if (platform === null) {
    throw new FrpcProvisionError(
      `unsupported platform for frpc: ${opts.platform}/${opts.arch} (supported: ${FRPC_PLATFORMS.join(', ')})`,
    )
  }

  const releases = opts.releases ?? FRPC_RELEASES
  const release = releases[platform]
  if (release === undefined) {
    throw new FrpcProvisionError(`no pinned frpc release for platform ${platform}`)
  }

  const computeSha256 = deps.computeSha256 ?? defaultSha256
  // Per-invocation-unique temp path: two concurrent provisions (e.g. overlapping autoupdate polls)
  // must never write/rename through the same temp file (TOCTOU). Same path is used for write+rm+rename.
  const tmpPath = join(opts.binDir, `${TMP_NAME}.${process.pid}.${randomBytes(6).toString('hex')}`)
  const binPath = join(opts.binDir, BIN_NAME)

  const bytes = await deps.fetch(release.url)

  await deps.fs.mkdir(opts.binDir)
  await deps.fs.writeFile(tmpPath, bytes)

  const digest = computeSha256(bytes).toLowerCase()
  const expected = release.sha256.toLowerCase()
  if (digest !== expected) {
    await deps.fs.rm(tmpPath)
    throw new FrpcProvisionError(
      `frpc SHA-256 mismatch for ${platform}: expected ${expected}, got ${digest} — nothing placed`,
    )
  }

  // Verified — extract ONLY the inner `frpc` from the archive. The extractor selects by basename and
  // rejects any unsafe entry name, so nothing derived from the archive can escape binDir. On any
  // extraction failure the (still-archive) temp is removed and nothing is placed.
  let frpcBytes: Uint8Array
  try {
    frpcBytes = extractFrpcBinary(bytes)
  } catch (err: unknown) {
    await deps.fs.rm(tmpPath)
    const detail = err instanceof Error ? err.message : 'unknown error'
    throw new FrpcProvisionError(
      `frpc extraction failed for ${platform}: ${detail} — nothing placed`,
    )
  }

  // Overwrite the temp with the verified extracted binary, grant exec, then promote atomically.
  await deps.fs.writeFile(tmpPath, frpcBytes)
  await deps.fs.chmod(tmpPath, EXEC_MODE)
  await deps.fs.rename(tmpPath, binPath)

  return { binPath, version: release.version, platform }
}
