/**
 * Greenhouse Harvest API connector for the OAuth-scopes verification source.
 *
 * Greenhouse has no formal token-introspection endpoint; we determine the
 * effective scope of an API key by probing a small set of read-only
 * endpoints with HTTP Basic Auth (`<apiKey>:` as username + empty
 * password). Each probe that returns 2xx becomes one
 * `{service: 'greenhouse', scope: '<name>:read'}` entry. The baseline
 * probe is `users/me` — if that fails with 401/403, the whole read is
 * reported as `unauthorized` because we have no proof the key is even
 * valid; subset 401/403s simply omit that scope.
 *
 * Write probes are deliberately skipped — we do not exercise mutation
 * endpoints against production tenants. This is documented in the
 * README; v1.1 may add a `--probe-writes` opt-in for staging tenants.
 *
 * Security discipline (mirrors PR #15 lessons applied to MCP-tools):
 *  - SSRF guard via `validateTargetEndpoint` if the env-var override
 *    `HERON_GREENHOUSE_BASE_URL` is set. Default URL is hardcoded.
 *  - Credentials never leak: the API key is passed only via the
 *    `Authorization` header, never echoed into error messages,
 *    warnings, or the `cause` field.
 *  - Control-char strip applied via `normalizeActualScope` at the
 *    chokepoint before scopes enter `ActualInventory`.
 *  - Prototype pollution: scope entries are typed objects, not raw
 *    `Record<string, unknown>` maps.
 *
 * Tracking: https://linear.app/theona/issue/AAP-48
 */

import { validateTargetEndpoint } from '../../../connectors/url-policy.js';
import { stripControlChars } from '../../../util/markdown-escape.js';
import type {
  ActualInventory,
  ActualScope,
  DeterministicSourceError,
} from '../../types.js';
import {
  scrubCauseValue,
  scrubCredentials,
  scrubMessage as scrubErrorMessage,
} from './scrub.js';

/**
 * Default Greenhouse Harvest base URL. The trailing slash matters:
 * probe paths are joined to this verbatim (no `URL` resolution to
 * sidestep accidental basename truncation).
 *
 * Override via `HERON_GREENHOUSE_BASE_URL` for tests that route
 * through a local proxy — the override is gated by
 * `validateTargetEndpoint` so an attacker cannot point the connector
 * at cloud metadata via this knob.
 */
export const GREENHOUSE_BASE_URL = 'https://harvest.greenhouse.io/v1/';

/**
 * Default per-probe timeout in milliseconds.
 *
 * F-3 (PR #16 round 2): probes must abort after a bounded wall-clock
 * so a slowloris or unresponsive Greenhouse instance cannot hang the
 * verification run at the OS socket timeout (often minutes). Operators
 * can override via the env var `HERON_GREENHOUSE_PROBE_TIMEOUT_MS` if
 * their tenancy has documented slow endpoints; invalid values fall
 * back to this default rather than erroring.
 */
export const GREENHOUSE_PROBE_TIMEOUT_MS_DEFAULT = 10_000;

/**
 * Probe table — one entry per scope we attempt to discover.
 *
 * `path` is concatenated to `GREENHOUSE_BASE_URL` verbatim. `scope`
 * is the string we emit on a successful probe. The set is intentionally
 * narrow (read-only, low-cost) so a verification run does not flood
 * the customer's Harvest account with probe traffic — at most four
 * 1-result fetches per run.
 *
 * `users/me` MUST be the first probe: it acts as the baseline auth
 * check. If `me` fails with 401, the connector reports `unauthorized`
 * without bothering to probe the other endpoints.
 */
export const GREENHOUSE_PROBES: ReadonlyArray<{ path: string; scope: string }> = [
  { path: 'users/me', scope: 'me:read' },
  { path: 'jobs?per_page=1', scope: 'jobs:read' },
  { path: 'candidates?per_page=1', scope: 'candidates:read' },
  { path: 'applications?per_page=1', scope: 'applications:read' },
];

/**
 * Minimal `fetch`-compatible signature for dependency injection.
 *
 * The test suite injects a stub here so we never touch the network.
 * Production resolves the symbol to `globalThis.fetch`.
 */
export type HttpClient = (url: string, init?: RequestInit) => Promise<Response>;

/**
 * Architectural chokepoint (parallel to `normalizeActualTool` in
 * `verification/sources/mcp-tools.ts`): strip control characters from
 * scope fields at the source boundary so every downstream renderer /
 * serialiser inherits clean data.
 *
 * Stripped — not replaced with a space — for the same reason
 * `normalizeActualTool` strips: `service` and `scope` are primary-key
 * components in the differ's `scopeKey` (`service\x00scope`). A
 * collapse-to-space would corrupt a legitimate scope like
 * `applications:read` if it ever carried a literal C0 byte; stripping
 * preserves the printable shape.
 *
 * Strip set is the same as `normalizeActualTool`: ASCII C0 `\x00-\x1f`,
 * DEL `\x7f`, C1 `\x80-\x9f`, U+2028, U+2029. See `stripControlChars`
 * in `util/markdown-escape.ts`.
 *
 * The current `ActualScope` type has no `_extra` field, so there is no
 * byte-size bound to apply here. If `ActualScope` ever grows an
 * `_extra` field analogous to `ActualTool._extra`, the same
 * `MAX_EXTRA_JSON_SIZE` bound from `verification/sources/mcp-tools.ts`
 * must be applied here.
 *
 * Never mutates the input — returns a fresh object.
 */
export function normalizeActualScope(scope: ActualScope): ActualScope {
  return {
    service: stripControlChars(scope.service),
    scope: stripControlChars(scope.scope),
  };
}

/** Result envelope for `readGreenhouseScopes`. */
export type ReadGreenhouseResult =
  | {
      ok: true;
      inventory: ActualInventory;
      /**
       * Optional warnings — present when some probes succeeded but
       * others failed (timeout, 5xx). The caller (orchestrator)
       * preserves these in the report so the auditor knows the scope
       * list is partial.
       */
      warnings?: string[];
    }
  | { ok: false; error: DeterministicSourceError };

export interface ReadGreenhouseArgs {
  apiKey: string;
  httpClient?: HttpClient;
  /** Wall-clock override for tests. Defaults to `new Date()`. */
  now?: () => Date;
}

/**
 * Run probe-based scope discovery against Greenhouse Harvest.
 *
 * Never throws — every error path is captured in the returned
 * `ReadGreenhouseResult`. The API key is never echoed back: the
 * `Authorization` header is the only place it appears and we never
 * include the header value in error messages, the `cause` field, or
 * the warnings array.
 */
export async function readGreenhouseScopes(args: ReadGreenhouseArgs): Promise<ReadGreenhouseResult> {
  // Resolve the base URL — env override is the only way to point the
  // connector elsewhere, and that path goes through the SSRF guard.
  const envOverride = process.env.HERON_GREENHOUSE_BASE_URL;
  let baseUrl: string;
  if (envOverride && envOverride.length > 0) {
    const policy = await validateTargetEndpoint(envOverride);
    if (!policy.ok) {
      return {
        ok: false,
        error: {
          kind: 'invalid_config',
          message: `HERON_GREENHOUSE_BASE_URL rejected by target_endpoint policy: ${policy.error.message}`,
        },
      };
    }
    // Normalise to a trailing slash so path concatenation is uniform.
    baseUrl = envOverride.endsWith('/') ? envOverride : `${envOverride}/`;
  } else {
    baseUrl = GREENHOUSE_BASE_URL;
  }

  const http: HttpClient = args.httpClient ?? defaultHttpClient();
  const authHeader = buildBasicAuthHeader(args.apiKey);
  const timeoutMs = resolveProbeTimeoutMs();

  // Issue the baseline probe first; if `users/me` doesn't succeed
  // with 2xx we cannot prove the key is even valid.
  const meEntry = GREENHOUSE_PROBES[0]!;
  const meUrl = `${baseUrl}${meEntry.path}`;
  let meResult: ProbeOutcome;
  try {
    meResult = await runProbe(http, meUrl, authHeader, timeoutMs);
  } catch (err) {
    // F-3: distinguish timeout (AbortError) from other transport
    // failures so the report can reflect the cause precisely.
    if (isAbortError(err)) {
      return {
        ok: false,
        error: {
          kind: 'timeout',
          message: `Greenhouse Harvest baseline probe timed out after ${timeoutMs}ms.`,
        },
      };
    }
    // Transport-level error on the baseline probe → unavailable.
    // `cause` is preserved but with the API key scrubbed in case the
    // transport echoed it back.
    return {
      ok: false,
      error: {
        kind: 'unavailable',
        message: `Greenhouse Harvest baseline probe failed: ${scrubMessage(err, args.apiKey)}`,
        cause: scrubCause(err, args.apiKey),
      },
    };
  }

  if (meResult.kind === 'auth-error') {
    return {
      ok: false,
      error: {
        kind: 'unauthorized',
        message: 'Greenhouse Harvest API key rejected (401/403 on users/me).',
      },
    };
  }

  if (meResult.kind === 'server-error') {
    // 5xx on baseline → cannot trust the key state at all.
    return {
      ok: false,
      error: {
        kind: 'unavailable',
        message: `Greenhouse Harvest returned ${meResult.status} on users/me; baseline probe failed.`,
      },
    };
  }

  // Baseline succeeded — collect scopes.
  const scopes: ActualScope[] = [];
  const warnings: string[] = [];
  scopes.push(normalizeActualScope({ service: 'greenhouse', scope: meEntry.scope }));

  // Run the remaining probes. Each one independent — a failure in
  // one does not abort the others.
  for (let i = 1; i < GREENHOUSE_PROBES.length; i++) {
    const probe = GREENHOUSE_PROBES[i]!;
    const url = `${baseUrl}${probe.path}`;
    let outcome: ProbeOutcome;
    try {
      outcome = await runProbe(http, url, authHeader, timeoutMs);
    } catch (err) {
      // F-3: surface timeouts distinctly so the auditor can tell a
      // hung probe from a transport error (DNS, TLS, etc).
      if (isAbortError(err)) {
        warnings.push(
          `Probe for scope '${probe.scope}' failed: timeout after ${timeoutMs}ms; scope omitted.`,
        );
        continue;
      }
      // Transport error — record a warning and skip this scope.
      warnings.push(
        `Probe for scope '${probe.scope}' failed: ${scrubMessage(err, args.apiKey)}`,
      );
      continue;
    }

    if (outcome.kind === 'ok') {
      scopes.push(normalizeActualScope({ service: 'greenhouse', scope: probe.scope }));
    } else if (outcome.kind === 'server-error') {
      warnings.push(
        `Probe for scope '${probe.scope}' returned status ${outcome.status}; scope omitted.`,
      );
    }
    // auth-error on a subset probe = scope not granted; no warning,
    // no scope entry. That is the expected case for narrow keys.
  }

  const now = args.now ?? (() => new Date());
  const inventory: ActualInventory = {
    source: 'oauth-scopes',
    capturedAt: now().toISOString(),
    scopes,
  };

  return warnings.length > 0
    ? { ok: true, inventory, warnings }
    : { ok: true, inventory };
}

// ─── Internal probe machinery ─────────────────────────────────────────

type ProbeOutcome =
  | { kind: 'ok'; status: number }
  | { kind: 'auth-error'; status: number }
  | { kind: 'server-error'; status: number };

async function runProbe(
  http: HttpClient,
  url: string,
  authHeader: string,
  timeoutMs: number,
): Promise<ProbeOutcome> {
  // F-2 (PR #16 round 2): manual redirect handling to prevent SSRF
  // guard bypass via redirect chains. Default `redirect: 'follow'`
  // would re-send the `Authorization: Basic <base64>` header to a
  // 3xx Location target. An attacker controlling a host that passes
  // the SSRF policy check (e.g. a public domain pointed at by
  // HERON_GREENHOUSE_BASE_URL) could redirect to
  // `http://169.254.169.254/` and harvest credentials via cloud
  // metadata, defeating `validateTargetEndpoint`. With manual
  // redirect, any 3xx is treated as a probe failure here.
  //
  // F-3: AbortSignal.timeout bounds each probe at a configurable
  // wall-clock so a slowloris or hung Greenhouse host cannot stall
  // the entire verification run. AbortError is caught at the call
  // site and surfaced as a `timeout` warning / error.
  //
  // NOTE: Response body is currently discarded (only status code is read).
  // If future code parses the body, add a size cap (e.g., 1 MiB) and reject
  // over-bound responses. AbortSignal.timeout does not bound the body-read
  // phase by default, so a hostile / oversized body could otherwise stream
  // arbitrarily long. Track via Linear AAP-48 follow-up; see F-4 in the
  // PR #16 security audit (deferred — latent today, no exploit path while
  // we read only `res.status`).
  const res = await http(url, {
    method: 'GET',
    headers: {
      'Authorization': authHeader,
      'Accept': 'application/json',
    },
    redirect: 'manual',
    signal: AbortSignal.timeout(timeoutMs),
  });
  if (res.status >= 200 && res.status < 300) {
    return { kind: 'ok', status: res.status };
  }
  if (res.status === 401 || res.status === 403) {
    return { kind: 'auth-error', status: res.status };
  }
  // 3xx falls through to server-error — see F-2 note above. A redirect
  // is a probe failure, not a successful scope discovery.
  return { kind: 'server-error', status: res.status };
}

/**
 * F-3: resolve the per-probe timeout. The env var
 * `HERON_GREENHOUSE_PROBE_TIMEOUT_MS` overrides the default for
 * operator flexibility. Invalid values (non-numeric, zero, negative,
 * non-finite, absurdly large) fall back to the default rather than
 * raising — verification must not crash on a malformed knob.
 */
function resolveProbeTimeoutMs(): number {
  const raw = process.env.HERON_GREENHOUSE_PROBE_TIMEOUT_MS;
  if (raw === undefined || raw === '') return GREENHOUSE_PROBE_TIMEOUT_MS_DEFAULT;
  const n = Number(raw);
  if (!Number.isFinite(n) || n <= 0 || n > 600_000) {
    return GREENHOUSE_PROBE_TIMEOUT_MS_DEFAULT;
  }
  return Math.floor(n);
}

/**
 * F-3: distinguish AbortError (timeout) from other thrown errors.
 * Node's fetch throws a `DOMException` with `name === 'AbortError'`
 * when an AbortSignal triggers, but tests / polyfills may throw a
 * plain `Error` with the same `name`. Match on `name` rather than
 * `instanceof DOMException` to stay portable.
 */
function isAbortError(err: unknown): boolean {
  if (err === null || typeof err !== 'object') return false;
  const name = (err as { name?: unknown }).name;
  return name === 'AbortError' || name === 'TimeoutError';
}

/**
 * Build the HTTP Basic Auth header for Greenhouse:
 *   `Authorization: Basic ${base64(<apiKey>:)}`
 *
 * Note the trailing colon and empty password — Greenhouse's
 * documented form.
 */
function buildBasicAuthHeader(apiKey: string): string {
  const encoded = Buffer.from(`${apiKey}:`, 'utf-8').toString('base64');
  return `Basic ${encoded}`;
}

/**
 * Greenhouse-specific Basic-Auth password: empty string.
 * BambooHR uses `'x'`; Greenhouse uses `''`. Carried through to the
 * shared scrub helper so the right `base64(<key>:<pwd>)` form is
 * redacted from echoed transport errors.
 */
const GREENHOUSE_BASIC_AUTH_OPTIONS = { basicAuthPassword: '' };

function scrubMessage(err: unknown, apiKey: string): string {
  return scrubErrorMessage(err, apiKey, GREENHOUSE_BASIC_AUTH_OPTIONS);
}

function scrubCause(err: unknown, apiKey: string): unknown {
  return scrubCauseValue(err, apiKey, GREENHOUSE_BASIC_AUTH_OPTIONS);
}

/**
 * Test-only re-export of the scrub helper so the F-5 unit tests can
 * verify the safety gate directly. Greenhouse callers always go
 * through the module-private `scrubMessage` / `scrubCause` above.
 */
export function scrubForTesting(value: string, apiKey: string): string {
  return scrubCredentials(value, apiKey, GREENHOUSE_BASIC_AUTH_OPTIONS);
}

/**
 * Default HTTP client — `globalThis.fetch`. Wrapped so the type is
 * `HttpClient` without exposing `fetch`'s overload signature to the
 * caller.
 */
function defaultHttpClient(): HttpClient {
  return (url: string, init?: RequestInit) => globalThis.fetch(url, init);
}
