// Test-rig loader.
//
// Reads `secrets/test-rigs/halflight.test.json` (or whatever `HALFLIGHT_TEST_
// RIG` points at), validates shape, and returns a typed `TestRig` — or `null`
// when the file is missing, malformed, or still carrying placeholder values
// from the freshly-cloned template. Returning `null` (instead of throwing)
// is the contract the public-repo experience rests on: the suite stays green
// for anyone without credentials, with skip markers in place of the network
// tier.
//
// All hostnames, OAuth client secrets, and access tokens live in this file
// alone — there is no env-var ladder, no per-test inline default, and no
// hardcoded host anywhere in `src/` or `__tests__/`. The release-source-zip
// script greps for any host pattern that matches the rig contents as a third
// layer of defence (the first two being gitignore and `secrets/` deletion).
//
// Schema versioning: the top-level `version` field is the migration anchor.
// Bumping it (and adding a migration branch in `parseRig`) keeps the loader
// backwards-compatible while still failing fast on outright shape drift.

import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'

export type RigType = 'pixelfed' | 'mastodon'

export type RigAccount = {
  /** Slot name from the rig's `rigs` map. Populated by the loader, not stored
   *  on disk. Used by globalSetup to find the right entry to mutate when it
   *  re-mints the Pixelfed access token at the start of a run. */
  slot: string
  type: RigType
  instance: string
  /** UUIDv4 — registry handle, not a security token. */
  accountId: string
  /** Numeric Mastodon-API id (`acct.id` from `verify_credentials`). */
  userId: string
  username: string
  acct: string
  displayName: string
  avatarUrl: string | null
  oauthClientId: string
  oauthClientSecret: string
  accessToken: string
  /** Mastodon spec'd to omit this; null is normal. Pixelfed Passport supplies it. */
  refreshToken: string | null
  /** ISO-8601 timestamp the access token was minted. Diagnostic only. */
  tokenObtainedAt: string
  scopes: string
  notes?: string
}

export type TestRig = {
  version: 1
  createdAt: string
  /** Slot name → account. Conventional slots are 'pixelfed' and 'mastodon';
   *  future setups may add e.g. 'pixelfed-secondary' for richer multi-account
   *  coverage. */
  rigs: Record<string, RigAccount>
}

/**
 * Resolve the rig file path. Honours the `HALFLIGHT_TEST_RIG` env override
 * (rare CI case where the file is materialised from a GitHub Actions secret);
 * falls back to `<repo-root>/secrets/test-rigs/halflight.test.json`.
 *
 * Exported so globalSetup can write the freshly-minted access token back to
 * the same file the per-suite loader reads from.
 */
export function resolveRigPath(): string {
  const override = process.env.HALFLIGHT_TEST_RIG
  if (override && override.length > 0) return override
  // __tests__/_helpers/loadTestRig.ts  →  repo root is two levels up.
  return resolve(__dirname, '..', '..', 'secrets', 'test-rigs', 'halflight.test.json')
}

function rigPath(): string {
  return resolveRigPath()
}

/** A field is a placeholder if it's the literal example string from the
 *  template. We detect both the angle-bracket form (`<PLACEHOLDER_FOO>`) and
 *  any string starting with `<PLACEHOLDER_`. Keeps the "skip cleanly without
 *  the rig" promise even when a developer half-fills the file. */
function isPlaceholder(value: unknown): boolean {
  if (typeof value !== 'string') return false
  return /^<PLACEHOLDER[_A-Z0-9]*>$/.test(value)
}

function isRigType(v: unknown): v is RigType {
  return v === 'pixelfed' || v === 'mastodon'
}

// Required string fields that must be non-empty for the rig entry to be
// usable. `displayName` is intentionally OUT — bot accounts often have it
// blank (verify_credentials returns ""), and it's diagnostic-only anyway.
const REQUIRED_STRING_FIELDS: ReadonlyArray<keyof RigAccount> = [
  'instance',
  'accountId',
  'userId',
  'username',
  'acct',
  'oauthClientId',
  'oauthClientSecret',
  'accessToken',
  'tokenObtainedAt',
  'scopes',
]

function parseRigAccount(slot: string, raw: unknown): RigAccount | string {
  if (!raw || typeof raw !== 'object') return `rigs.${slot}: not an object`
  const r = raw as Record<string, unknown>
  if (!isRigType(r.type)) return `rigs.${slot}.type: must be 'pixelfed' or 'mastodon'`
  for (const k of REQUIRED_STRING_FIELDS) {
    const v = r[k]
    if (typeof v !== 'string' || v.length === 0) {
      return `rigs.${slot}.${k}: missing or empty`
    }
    if (isPlaceholder(v)) return `rigs.${slot}.${k}: still a <PLACEHOLDER_*> value`
  }
  return {
    slot,
    type: r.type,
    instance: r.instance as string,
    accountId: r.accountId as string,
    userId: r.userId as string,
    username: r.username as string,
    acct: r.acct as string,
    displayName: typeof r.displayName === 'string' ? r.displayName : '',
    avatarUrl: typeof r.avatarUrl === 'string' ? r.avatarUrl : null,
    oauthClientId: r.oauthClientId as string,
    oauthClientSecret: r.oauthClientSecret as string,
    accessToken: r.accessToken as string,
    refreshToken: typeof r.refreshToken === 'string' ? r.refreshToken : null,
    tokenObtainedAt: r.tokenObtainedAt as string,
    scopes: r.scopes as string,
    notes: typeof r.notes === 'string' ? r.notes : undefined,
  }
}

/** Cached result so repeated `loadTestRig()` calls within one process don't
 *  re-read and re-parse the JSON. The cache is keyed by resolved path so an
 *  env-var override during the same process still finds the right file. */
let _cached: { path: string; rig: TestRig | null } | null = null

/**
 * Load and validate the test rig. Returns `null` when:
 *   - the file does not exist at the resolved path,
 *   - the JSON is malformed,
 *   - the top-level `placeholder: true` flag is set (template hasn't been
 *     filled in yet),
 *   - the `version` field is unrecognised,
 *   - any required field is missing, empty, or a `<PLACEHOLDER_*>` value.
 *
 * Never throws. The release-script's grep is the safety net for an
 * accidentally-committed rig; the loader's contract here is "fail soft so
 * cloners can run the suite."
 */
export function loadTestRig(): TestRig | null {
  const path = rigPath()
  if (_cached && _cached.path === path) return _cached.rig

  let raw: string
  try {
    raw = readFileSync(path, 'utf8')
  } catch {
    _cached = { path, rig: null }
    return null
  }

  let parsed: unknown
  try {
    parsed = JSON.parse(raw)
  } catch (e) {
    // eslint-disable-next-line no-console
    console.warn(`[loadTestRig] ${path}: invalid JSON — ${(e as Error).message}`)
    _cached = { path, rig: null }
    return null
  }

  if (!parsed || typeof parsed !== 'object') {
    _cached = { path, rig: null }
    return null
  }
  const top = parsed as Record<string, unknown>
  if (top.placeholder === true) {
    _cached = { path, rig: null }
    return null
  }
  if (top.version !== 1) {
    // eslint-disable-next-line no-console
    console.warn(
      `[loadTestRig] ${path}: unsupported version ${String(top.version)} — re-run scripts/dev/bootstrap-test-rig.sh`
    )
    _cached = { path, rig: null }
    return null
  }
  if (!top.rigs || typeof top.rigs !== 'object') {
    _cached = { path, rig: null }
    return null
  }

  const rigs: Record<string, RigAccount> = {}
  for (const [slot, entry] of Object.entries(top.rigs as Record<string, unknown>)) {
    const result = parseRigAccount(slot, entry)
    if (typeof result === 'string') {
      // eslint-disable-next-line no-console
      console.warn(`[loadTestRig] ${path}: ${result}`)
      _cached = { path, rig: null }
      return null
    }
    rigs[slot] = result
  }

  const rig: TestRig = {
    version: 1,
    createdAt: typeof top.createdAt === 'string' ? top.createdAt : '',
    rigs,
  }
  _cached = { path, rig }
  return rig
}

/** First rig entry matching the given protocol type, or `undefined`. */
export function getRigByType(rig: TestRig, type: RigType): RigAccount | undefined {
  for (const entry of Object.values(rig.rigs)) {
    if (entry.type === type) return entry
  }
  return undefined
}

/** Every hostname referenced by the rig — fed to the output sanitiser so a
 *  failing test never leaks the bot servers into stdout, snapshot files, or
 *  CI reporter output. */
export function rigHostnames(rig: TestRig): string[] {
  return Array.from(new Set(Object.values(rig.rigs).map((r) => r.instance)))
}

// ---------------------------------------------------------------------------
// describeWithRig — `describe.skip` when the rig is missing.
//
// Three variants by progressive specificity:
//   - `describeWithRig` — any rig is enough; the body gets the whole rig.
//   - `describeWithRigType` — needs at least one entry of a given protocol.
//   - `describeWithBothRigs` — needs both pixelfed AND mastodon.
//
// All three resolve `loadTestRig()` at describe-collection time (jest's
// describe phase), so the skip markers materialise before any test runs.
// ---------------------------------------------------------------------------

type DescribeFn = (name: string, fn: () => void) => void

declare const describe: DescribeFn & { skip: DescribeFn }
declare const it: DescribeFn & { skip: DescribeFn }

function skipBlock(name: string, reason: string): void {
  // eslint-disable-next-line jest/no-disabled-tests
  describe.skip(`${name} (skipped: ${reason})`, () => {
    it.skip('rig not configured', () => {})
  })
}

const RIG_HINT = 'run scripts/dev/bootstrap-test-rig.sh'

export function describeWithRig(name: string, fn: (rig: TestRig) => void): void {
  const rig = loadTestRig()
  if (!rig) {
    skipBlock(name, `no test rig — ${RIG_HINT}`)
    return
  }
  describe(name, () => fn(rig))
}

export function describeWithRigType(
  type: RigType,
  name: string,
  fn: (entry: RigAccount, rig: TestRig) => void
): void {
  const rig = loadTestRig()
  if (!rig) {
    skipBlock(name, `no test rig — ${RIG_HINT}`)
    return
  }
  const entry = getRigByType(rig, type)
  if (!entry) {
    skipBlock(name, `no ${type} rig entry`)
    return
  }
  describe(name, () => fn(entry, rig))
}

export function describeWithBothRigs(
  name: string,
  fn: (entries: { pixelfed: RigAccount; mastodon: RigAccount }, rig: TestRig) => void
): void {
  const rig = loadTestRig()
  if (!rig) {
    skipBlock(name, `no test rig — ${RIG_HINT}`)
    return
  }
  const pixelfed = getRigByType(rig, 'pixelfed')
  const mastodon = getRigByType(rig, 'mastodon')
  if (!pixelfed || !mastodon) {
    skipBlock(
      name,
      `multi-protocol test needs both pixelfed and mastodon rigs — ${RIG_HINT}`
    )
    return
  }
  describe(name, () => fn({ pixelfed, mastodon }, rig))
}
