/**
 * HTTP protocol runtime for connector tools.
 *
 * Issues an HTTP request from Forge (server-side, not the user's browser).
 * Used by tools where the upstream offers a clean REST API and no DOM
 * scraping is needed (GitHub API, GitLab API, internal services).
 *
 * Templates: {base_url}, {settings.*}, {args.*} are expanded into url,
 * query values, header values, and body. Body can be a string or an
 * object — strings are sent as-is after expansion; objects are walked
 * and any string leaves are expanded, then JSON.stringify'd.
 *
 * Response: the body is returned verbatim (truncated to 8 KB for the
 * LLM context) with a status-line preamble. Non-2xx is surfaced as
 * is_error so the LLM can react.
 */

import type { HttpRequestSpec, ConnectorTool, ConnectorAuth, ConnectorFieldSchema } from '../../connectors/types';
import { expandAllTokens } from '../../plugins/templates';

export interface HttpProtocolArgs {
  tool: ConnectorTool;
  settings: Record<string, any>;
  args: Record<string, any>;
  /**
   * Connector-level auth. Tool-level `tool.auth` takes precedence.
   * Forge resolves the scheme into the right header/query at dispatch
   * time so manifests don't have to hand-craft Authorization headers
   * or base64-encode credentials.
   */
  connectorAuth?: ConnectorAuth;
  /**
   * When true, return the full response body without the 8KB cap. Used by
   * the Jobs scheduler — it parses JSON, not feeds the response into an
   * LLM context, so truncation breaks `JSON.parse` for any response
   * larger than 8KB (Todos / search responses routinely exceed this).
   */
  noTruncation?: boolean;
  /**
   * Set to false to disable TLS cert verification (self-signed appliances:
   * FortiNAC, ESXi, internal services). Default true. Manifests opt in
   * via top-level `http: { verify_tls: false }`; tool-dispatcher forwards
   * that value here.
   */
  verifyTls?: boolean;
}

export interface HttpProtocolResult {
  content: string;
  is_error?: boolean;
}

const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_TIMEOUT_MS = 300_000;
const MAX_BODY_BYTES = 8 * 1024;

function expandObjectLeaves(obj: any, settings: Record<string, any>, args: Record<string, any>): any {
  if (obj == null) return obj;
  if (typeof obj === 'string') return expandAllTokens(obj, settings, args);
  if (Array.isArray(obj)) return obj.map((v) => expandObjectLeaves(v, settings, args));
  if (typeof obj === 'object') {
    const out: Record<string, any> = {};
    for (const [k, v] of Object.entries(obj)) out[k] = expandObjectLeaves(v, settings, args);
    return out;
  }
  return obj;
}

/**
 * Encode a string value per its parameter's `url_encoding` declaration.
 * Default `uri_component` matches encodeURIComponent (slashes encoded).
 * `none` is raw, for pre-formatted paths (e.g. Jenkins folder paths
 * `job/team/job/build`). `path_segments` encodes each `/`-separated
 * piece but preserves the slashes — good for human-readable paths
 * that contain spaces or unicode.
 */
function encodePathValue(raw: string, mode: ConnectorFieldSchema['url_encoding'] | undefined): string {
  switch (mode) {
    case 'none':
      return raw;
    case 'path_segments':
      return raw.split('/').map(encodeURIComponent).join('/');
    case 'uri_component':
    case undefined:
    default:
      return encodeURIComponent(raw);
  }
}

/**
 * Expand `{args.X}` placeholders in a URL path. Each arg's encoding is
 * decided by its parameter schema's `url_encoding` field (default
 * `uri_component` — see `encodePathValue` for the modes). `{settings.X}`
 * is NOT encoded — `{settings.base_url}` is the scheme + host (with its
 * own `://` and `/`), which must stay literal.
 */
function expandUrlPath(
  template: string,
  settings: Record<string, any>,
  args: Record<string, any>,
  paramSchemas?: Record<string, ConnectorFieldSchema>,
): string {
  // First handle settings.* with raw substitution (keeps base_url intact).
  let out = expandAllTokens(template, settings, {});
  // Then handle args.* with per-parameter URL encoding.
  out = out.replace(/\{args\.([^{}]+)\}/g, (full, rawKey) => {
    const path = String(rawKey).trim().split('.');
    let v: any = args;
    for (const p of path) {
      if (v == null || typeof v !== 'object') { v = undefined; break; }
      v = v[p];
    }
    if (v == null) return full;
    const s = typeof v === 'string' ? v : (typeof v === 'number' || typeof v === 'boolean' ? String(v) : JSON.stringify(v));
    // Encoding mode comes from the top-level parameter's schema. Nested
    // arg paths inherit their root parameter's encoding — common case is
    // a flat scalar parameter so this matters rarely.
    const rootParam = path[0];
    const mode = paramSchemas?.[rootParam]?.url_encoding;
    return encodePathValue(s, mode);
  });
  return out;
}

/**
 * Collapse consecutive slashes in the URL's pathname only — fixes the
 * `https://host//api/...` artifact when settings.base_url has a trailing
 * slash AND the manifest path starts with `/`. Query string and fragment
 * are NOT touched (a query value like `?url=//foo` is left alone).
 *
 * If `url` doesn't parse, returns it unchanged.
 */
function collapsePathSlashes(url: string): string {
  try {
    const u = new URL(url);
    u.pathname = u.pathname.replace(/\/{2,}/g, '/');
    return u.toString();
  } catch {
    return url;
  }
}

function buildUrl(
  spec: HttpRequestSpec,
  settings: Record<string, any>,
  args: Record<string, any>,
  paramSchemas?: Record<string, ConnectorFieldSchema>,
): string {
  const base = collapsePathSlashes(
    expandUrlPath(spec.url, settings, args, paramSchemas),
  );
  if (!spec.query) return base;
  const url = new URL(base);
  for (const [k, raw] of Object.entries(spec.query)) {
    const v = expandAllTokens(String(raw), settings, args);
    // Drop query params whose value didn't substitute (caller didn't pass
    // that optional arg). Leaving `{args.foo}` in the URL would send it
    // verbatim and many APIs return 400 on unknown values.
    if (/\{(args|settings)\./.test(v) || v === '') continue;
    url.searchParams.append(k, v);
  }
  return url.toString();
}

/**
 * Apply a connector/tool auth scheme onto an outbound request. Resolves
 * templated `{settings.*}` inside auth values, base64-encodes basic
 * credentials, and chooses between header / query placement. The URL is
 * passed by reference (returned as a new string if the auth scheme
 * appends a query param). Centralised so the chat dispatcher and the
 * connector-test probe stay consistent.
 */
// Bearer-token-exchange cache (BD, etc) — module-level so repeated tool
// calls within the bearer's TTL skip the exchange round-trip.
//   key = `<api_token>|<exchange_url>` (api_token resolved after template
//   expansion so different settings rotations get separate entries).
const _bearerCache = new Map<string, { bearer: string; expiresAt: number }>();

function pickByPath(obj: any, path: string | undefined, fallback: string): any {
  const p = (path && path.trim()) || fallback;
  const parts = p.split('.').filter(Boolean);
  let cur = obj;
  for (const k of parts) {
    if (cur == null || typeof cur !== 'object') return undefined;
    cur = cur[k];
  }
  return cur;
}

async function exchangeBearerToken(
  auth: Extract<ConnectorAuth, { type: 'bearer-token-exchange' }>,
  settings: Record<string, any>,
  args: Record<string, any>,
  verifyTls?: boolean,
): Promise<string> {
  const exp = (s: string | undefined) =>
    s == null ? '' : expandAllTokens(String(s), settings, args);
  const apiToken = exp(auth.api_token);
  // Same pathname-only collapse as buildUrl — guards against trailing
  // slash on settings.base_url producing `host//api/...`.
  const exchangeUrl = collapsePathSlashes(exp(auth.exchange_url));
  if (!exchangeUrl) throw new Error('bearer-token-exchange: exchange_url is empty');

  // Two credential modes:
  //   header-mode → exchange_auth_header (BD: "token <api_token>")
  //   body-mode   → exchange_body (NCM: {username, password})
  // Cache key derives from whichever credential identity is in play, so
  // password rotation invalidates cache, and multi-host installs cache
  // per (host, credentials) pair.
  const usingBody = !!auth.exchange_body;
  const credIdentity = usingBody
    ? JSON.stringify(
        Object.fromEntries(
          Object.entries(auth.exchange_body!).map(([k, v]) => [k, exp(String(v ?? ''))]),
        ),
      )
    : apiToken;
  if (!credIdentity) throw new Error('bearer-token-exchange: no credentials (api_token or exchange_body)');
  const key = `${credIdentity}|${exchangeUrl}`;
  const cached = _bearerCache.get(key);
  if (cached && cached.expiresAt > Date.now() + 60_000) return cached.bearer;

  const headers: Record<string, string> = {};
  let body: string | undefined;
  if (usingBody) {
    headers['content-type'] = 'application/json';
    const expanded: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(auth.exchange_body!)) {
      expanded[k] = typeof v === 'string' ? exp(v) : v;
    }
    body = JSON.stringify(expanded);
  } else {
    headers['Authorization'] = exp(auth.exchange_auth_header || `token ${auth.api_token}`);
  }
  if (auth.exchange_headers) {
    for (const [k, v] of Object.entries(auth.exchange_headers)) headers[k] = exp(v);
  }
  // Honour connector-level http.verify_tls — self-signed appliances
  // (NCM, NAC, ESXi …) need undici with rejectUnauthorized:false. Default
  // fetch barfs with "fetch failed" otherwise.
  const exchangeFetchInit = { method: auth.exchange_method || 'POST', headers, body };
  let res: Response;
  if (verifyTls === false) {
    const { fetch: undiciFetch, Agent } = await import('undici');
    const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
    res = await undiciFetch(exchangeUrl, { ...exchangeFetchInit, dispatcher }) as unknown as Response;
  } else {
    res = await fetch(exchangeUrl, exchangeFetchInit);
  }
  if (!res.ok) {
    const errBody = await res.text().catch(() => '');
    throw new Error(`bearer-token-exchange: ${res.status} ${res.statusText}: ${errBody.slice(0, 200)}`);
  }
  const j: any = await res.json().catch(() => ({}));
  const bearer = pickByPath(j, auth.bearer_path, 'bearerToken');
  const expiresMs = Number(pickByPath(j, auth.expires_path, 'expiresInMilliseconds')) || 0;
  if (typeof bearer !== 'string' || !bearer) {
    throw new Error(`bearer-token-exchange: missing bearer at "${auth.bearer_path || 'bearerToken'}" in response`);
  }
  // Cache for the reported TTL, the fallback ttl, or 5 min default.
  const fallbackMs = (auth.expires_ttl_sec || 300) * 1000;
  const ttl = expiresMs > 0 ? expiresMs : fallbackMs;
  _bearerCache.set(key, { bearer, expiresAt: Date.now() + ttl });
  return bearer;
}

export async function applyAuth(
  url: string,
  headers: Headers,
  auth: ConnectorAuth | undefined,
  settings: Record<string, any>,
  args: Record<string, any> = {},
  verifyTls?: boolean,
): Promise<string> {
  if (!auth || auth.type === 'none') return url;
  const exp = (s: string) => expandAllTokens(String(s ?? ''), settings, args);
  switch (auth.type) {
    case 'basic': {
      const u = exp(auth.username);
      const p = exp(auth.password);
      const token = Buffer.from(`${u}:${p}`, 'utf-8').toString('base64');
      headers.set('Authorization', `Basic ${token}`);
      return url;
    }
    case 'bearer': {
      headers.set('Authorization', `Bearer ${exp(auth.token)}`);
      return url;
    }
    case 'header': {
      headers.set(auth.name, exp(auth.value));
      return url;
    }
    case 'query': {
      const u = new URL(url);
      u.searchParams.set(auth.name, exp(auth.value));
      return u.toString();
    }
    case 'bearer-token-exchange': {
      const bearer = await exchangeBearerToken(auth, settings, args, verifyTls);
      const format = auth.bearer_format || 'bearer';
      headers.set('Authorization', format === 'bare' ? bearer : `Bearer ${bearer}`);
      return url;
    }
  }
  return url;
}

function buildHeaders(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): Headers {
  const h = new Headers();
  if (spec.headers) {
    for (const [k, raw] of Object.entries(spec.headers)) {
      h.set(k, expandAllTokens(String(raw), settings, args));
    }
  }
  return h;
}

function buildBody(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): { body?: string; contentType?: string } {
  if (spec.body != null) {
    if (typeof spec.body === 'string') {
      const expanded = expandAllTokens(spec.body, settings, args);
      // Empty string after expansion = no body. Lets manifests use
      // `body: "{args.body_json}"` with an empty default — Forge sends
      // no body for GET/DELETE-style calls without the LLM needing to
      // know about the body field at all.
      if (expanded === '') return {};
      return { body: expanded };
    }
    const obj = expandObjectLeaves(spec.body, settings, args);
    return { body: JSON.stringify(obj), contentType: 'application/json' };
  }
  if (spec.body_form != null || spec.body_form_inject != null || spec.body_form_inject_from != null) {
    return buildFormBody(spec.body_form, spec.body_form_inject, spec.body_form_inject_from, settings, args);
  }
  return {};
}

/**
 * Serialise an object into application/x-www-form-urlencoded body.
 * The spec value can be:
 *  - a literal placeholder `{args.NAME}` — resolved to the named arg
 *    (must be a plain object); used by Jenkins trigger_build to take a
 *    dynamic `params` map of build parameters.
 *  - an inline object whose leaves get template-expanded — used when
 *    the form keys are static.
 *  - any other string — treated as a JSON-string template, parsed, then
 *    serialised (less common, but lets manifests build the body inline).
 *
 * null/undefined values are dropped (no empty `KEY=`). Non-string values
 * are stringified.
 */
function buildFormBody(spec: string | Record<string, unknown> | undefined, inject: Record<string, string> | undefined, injectFrom: string | undefined, settings: Record<string, any>, args: Record<string, any>): { body?: string; contentType?: string } {
  let obj: any = null;
  if (spec != null) {
    if (typeof spec === 'string') {
      const m = spec.match(/^\{args\.([^{}]+)\}$/);
      if (m) {
        obj = args[m[1]];
        // LLMs frequently JSON-stringify an object arg even when the
        // tool schema declares it as `type: json`. Parse it back so the
        // form serialisation works either way.
        if (typeof obj === 'string') {
          try { obj = JSON.parse(obj); } catch { /* leave as null below */ }
        }
      } else {
        const expanded = expandAllTokens(spec, settings, args);
        try { obj = JSON.parse(expanded); } catch { obj = null; }
      }
    } else {
      obj = expandObjectLeaves(spec, settings, args);
    }
  }
  if (obj == null) obj = {};
  if (typeof obj !== 'object' || Array.isArray(obj)) obj = {};

  // Server-side inject — typically secrets pulled from settings.
  // BOTH key and value are templated (against settings only, NOT args,
  // so the LLM can't shadow injected keys). Templated keys let one
  // manifest target different Jenkins jobs whose param names vary —
  // each instance config sets the key name (e.g. TOKEN_PASSWORD)
  // alongside the value source (e.g. {settings.gitlab_pat}). Entries
  // where the key OR value comes back empty / unresolved get dropped.
  if (inject) {
    for (const [rawKey, rawVal] of Object.entries(inject)) {
      const k = expandAllTokens(String(rawKey), settings, {});
      if (!k || /\{(settings|args)\./.test(k)) continue;
      const v = expandAllTokens(String(rawVal), settings, {});
      if (!v || /\{(settings|args)\./.test(v)) continue;
      obj[k] = v;
    }
  }

  // body_form_inject_from — settings[X] is expected to be an
  // `instances`-shaped array (each row { name, value, ... }). Inject
  // every row as one form key/value pair. Lets the connector defer
  // the actual key+value choices to per-user instance config without
  // hardcoding them in the manifest. Rows with empty name or value
  // are dropped.
  //
  // Values pass through expandAllTokens — Jenkins' inject_params
  // typically reads cross-connector refs like
  //   { name: TOKEN_USER,     value: "{connector:gitlab.token_name}" }
  //   { name: TOKEN_PASSWORD, value: "{connector:gitlab.token}"     }
  // and we want the runtime gitlab value, not the literal string.
  // Key gets the same treatment so connectors can template that too.
  // Rows where either side resolves to empty (or stays an unresolved
  // {settings|args}.X) get dropped — same guard as the static inject
  // branch above.
  if (injectFrom) {
    let rows: any = (settings as any)[injectFrom];
    if (typeof rows === 'string') {
      try { rows = JSON.parse(rows); } catch { rows = null; }
    }
    if (Array.isArray(rows)) {
      for (const row of rows) {
        if (!row || typeof row !== 'object') continue;
        const rawK = typeof row.name === 'string' ? row.name : '';
        const rawV = typeof row.value === 'string' ? row.value : (row.value == null ? '' : String(row.value));
        const k = expandAllTokens(rawK, settings, args).trim();
        const v = expandAllTokens(rawV, settings, args);
        if (!k || !v) continue;
        if (/\{(settings|args|connector:)/.test(k) || /\{(settings|args|connector:)/.test(v)) continue;
        obj[k] = v;
      }
    }
  }

  const usp = new URLSearchParams();
  for (const [k, v] of Object.entries(obj)) {
    if (v == null) continue;
    usp.append(k, typeof v === 'string' ? v : String(v));
  }
  return { body: usp.toString(), contentType: 'application/x-www-form-urlencoded' };
}

function truncate(s: string): { text: string; truncated: boolean; totalBytes: number } {
  const buf = Buffer.from(s, 'utf-8');
  if (buf.byteLength <= MAX_BODY_BYTES) return { text: s, truncated: false, totalBytes: buf.byteLength };
  const slice = buf.subarray(0, MAX_BODY_BYTES).toString('utf-8');
  return { text: slice, truncated: true, totalBytes: buf.byteLength };
}

export async function runHttp({ tool, settings, args, connectorAuth, noTruncation, verifyTls }: HttpProtocolArgs): Promise<HttpProtocolResult> {
  const spec = tool.request;
  if (!spec || !spec.url) {
    return { content: 'http tool missing `request.url`', is_error: true };
  }
  const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(1, Number(tool.timeout_ms || DEFAULT_TIMEOUT_MS)));

  // Apply parameter defaults from the manifest so templates like
  // {args.per_page} can resolve when the LLM omits the field. LLMs are
  // inconsistent about including defaults in tool calls; merging them
  // here is closer to how users expect a schema `default:` to behave.
  const argsWithDefaults: Record<string, any> = { ...args };
  if (tool.parameters) {
    for (const [name, schema] of Object.entries(tool.parameters)) {
      if (argsWithDefaults[name] === undefined && schema && (schema as any).default !== undefined) {
        argsWithDefaults[name] = (schema as any).default;
      }
    }
  }

  // Expand `{args.method}` etc. before uppercasing — call_api-style tools
  // template the method from a tool param. Without expansion the literal
  // `{args.method}` reached fetch as `{ARGS.METHOD}` and bombed with
  // "is not a valid HTTP method".
  const rawMethod = expandAllTokens(spec.method || 'GET', settings, argsWithDefaults);
  const method = (rawMethod || 'GET').toUpperCase();

  let url = buildUrl(spec, settings, argsWithDefaults, tool.parameters);
  const headers = buildHeaders(spec, settings, argsWithDefaults);
  const { body, contentType } = buildBody(spec, settings, argsWithDefaults);
  if (body != null && contentType && !headers.has('content-type')) headers.set('content-type', contentType);

  // Tool-level auth overrides connector-level. `{ type: 'none' }` is a
  // valid override that disables auth entirely (public endpoint).
  const effectiveAuth = tool.auth ?? connectorAuth;
  url = await applyAuth(url, headers, effectiveAuth, settings, argsWithDefaults, verifyTls);

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  let res: Response;
  try {
    if (verifyTls === false) {
      // Skip TLS cert verification — for self-signed appliances (NAC,
      // ESXi, etc.). Use undici's own fetch + Agent so dispatcher and
      // fetch are version-matched; passing an external undici Agent
      // into Node's bundled global fetch fails with UND_ERR_INVALID_ARG
      // because Node 22 ships an older undici than the installed one.
      const { fetch: undiciFetch, Agent } = await import('undici');
      const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
      res = await undiciFetch(url, { method, headers, body, signal: controller.signal, dispatcher }) as unknown as Response;
    } else {
      res = await fetch(url, { method, headers, body, signal: controller.signal });
    }
  } catch (e) {
    clearTimeout(timer);
    const err = e as Error & { cause?: { message?: string; code?: string } };
    const cause = err.cause ? ` (cause: ${err.cause.code || ''} ${err.cause.message || ''})` : '';
    return { content: `http request failed: ${err.message}${cause}`, is_error: true };
  }
  clearTimeout(timer);

  const text = await res.text().catch(() => '');
  if (noTruncation) {
    // Jobs scheduler / preview path — return the body verbatim so
    // JSON.parse works on responses larger than MAX_BODY_BYTES. No
    // preamble either; callers parse content directly.
    return { content: text, is_error: !res.ok };
  }
  const { text: shown, truncated, totalBytes } = truncate(text);
  let preamble = `HTTP ${res.status} ${res.statusText} · ${method} ${url}\n`;
  // Surface user-declared response headers in the preamble so connector
  // bash callers can grep them out before the body parse. Set-Cookie is
  // the canonical case — appliances like FortiNAC put the session token
  // in JSESSIONID and there's no other way for an http: tool to fish it
  // out. Headers are lower-cased on lookup; emitted name preserves the
  // schema's casing for readability.
  if (spec.capture_response_headers && spec.capture_response_headers.length) {
    for (const name of spec.capture_response_headers) {
      const v = res.headers.get(name);
      if (v != null) preamble += `${name}: ${v}\n`;
    }
  }
  preamble += truncated ? `(showing ${MAX_BODY_BYTES} of ${totalBytes} bytes — truncated)\n\n` : '\n';
  return {
    content: preamble + shown,
    is_error: !res.ok,
  };
}
