/**
 * 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 } from '../../connectors/types';
import { expandAllTokens } from '../../plugins/templates';

export interface HttpProtocolArgs {
  tool: ConnectorTool;
  settings: Record<string, any>;
  args: Record<string, any>;
  /**
   * 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;
}

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;
}

/**
 * Expand `{args.X}` placeholders in a URL path with the value URL-
 * encoded. `{settings.X}` is NOT encoded — `{settings.base_url}` is the
 * scheme + host (with its own `://` and `/`), which must stay literal.
 *
 * Why: GitLab and many REST APIs accept either a numeric id or a
 * URL-encoded namespace path (`fortinac%2FFortiNAC`) as the project
 * identifier in the path. Without encoding, `args.project_id =
 * "fortinac/FortiNAC"` interpolates as a raw `/` and turns
 * `/projects/{args.project_id}/...` into `/projects/fortinac/FortiNAC/...`
 * (extra path segment), which the API can't parse. Numeric ids encode
 * to themselves — no regression.
 */
function expandUrlPath(template: string, settings: Record<string, any>, args: Record<string, any>): string {
  // First handle settings.* with raw substitution (keeps base_url intact).
  let out = expandAllTokens(template, settings, {});
  // Then handle args.* with 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));
    return encodeURIComponent(s);
  });
  return out;
}

function buildUrl(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): string {
  const base = expandUrlPath(spec.url, settings, args);
  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();
}

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) return {};
  if (typeof spec.body === 'string') {
    return { body: expandAllTokens(spec.body, settings, args) };
  }
  const obj = expandObjectLeaves(spec.body, settings, args);
  return { body: JSON.stringify(obj), contentType: 'application/json' };
}

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, noTruncation }: HttpProtocolArgs): Promise<HttpProtocolResult> {
  const spec = tool.request;
  if (!spec || !spec.url) {
    return { content: 'http tool missing `request.url`', is_error: true };
  }
  const method = (spec.method || 'GET').toUpperCase();
  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;
      }
    }
  }

  const url = buildUrl(spec, settings, argsWithDefaults);
  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);

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

  let res: Response;
  try {
    res = await fetch(url, { method, headers, body, signal: controller.signal });
  } catch (e) {
    clearTimeout(timer);
    return { content: `http request failed: ${(e as Error).message}`, 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);
  const preamble = `HTTP ${res.status} ${res.statusText} · ${method} ${url}\n` +
                   (truncated ? `(showing ${MAX_BODY_BYTES} of ${totalBytes} bytes — truncated)\n\n` : '\n');
  return {
    content: preamble + shown,
    is_error: !res.ok,
  };
}
