// lib/tools/adapters/http-api-call.js — the ONE generic HTTP executor (Option D).
//
// Every connector endpoint (<slug>.<endpoint>) is backed by this single adapter.
// It interpolates DATA ONLY (url, headers, body) from an inert connector manifest
// — it never eval()s or imports anything the manifest supplies. Load-bearing
// security properties, all enforced here at call time:
//   1. Host allowlist  — the resolved URL host must be in manifest.allowed_hosts.
//   2. SSRF / private-IP block — reuses assertPublicHttpUrl from web.js (net.isIP).
//   3. Secret-by-reference — the credential is resolved from the encrypted vault
//      by name (auth.*_ref). It is injected into the request and NEVER returned,
//      logged, or echoed in the response.
//   4. Egress — every call flows through wrapFetch so host/method/status/bytes
//      land in tool_egress.
// Risk + approval gating happen UPSTREAM in executeRegistryTool on the endpoint's
// declared (clamped) risk tier before this adapter is ever called.

import { getSecret, setSecret } from '../../secrets.js';
import { wrapFetch } from '../../egress.js';
import { assertPublicHttpUrl } from './web.js';
import { resolveConnectorEndpoint } from '../connectors/registry.js';
import { resolveProvider } from '../../oauth/provider-registry.js';
import { refreshAccessToken } from '../../oauth/flow.js';

const MAX_RESPONSE_BYTES = 512000;
const RESERVED_KEYS = new Set(['__connector_slug', '__endpoint', 'approval_id', 'body', 'query']);

function pathParamNames(path) {
  const out = [];
  const re = /\{([a-z][a-z0-9_]*)\}/g;
  let m;
  while ((m = re.exec(path)) !== null) out.push(m[1]);
  return out;
}

function buildAuth(auth = {}) {
  // Returns { headers: {}, query: {} } with the resolved secret injected.
  // Throws { code:'secret_missing' } when a referenced secret is absent.
  const headers = {};
  const query = {};
  const need = (name) => {
    const v = getSecret(name);
    if (v == null || v === '') {
      const err = new Error(`Required secret not configured: ${name}`);
      err.code = 'secret_missing';
      err.secret_ref = name;
      throw err;
    }
    return String(v);
  };
  if (auth.type === 'bearer') {
    const secret = need(auth.secret_ref);
    headers[auth.header || 'Authorization'] = `${auth.prefix ?? 'Bearer '}${secret}`;
  } else if (auth.type === 'api_key') {
    const secret = need(auth.secret_ref);
    if (auth.query_param) {
      query[auth.query_param] = `${auth.prefix ?? ''}${secret}`;
    } else {
      headers[auth.header || 'Authorization'] = `${auth.prefix ?? ''}${secret}`;
    }
  } else if (auth.type === 'basic') {
    const user = need(auth.username_ref);
    const pass = need(auth.password_ref);
    headers.Authorization = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
  } else if (auth.type === 'oauth2') {
    const secret = need(auth.access_token_ref);
    headers[auth.header || 'Authorization'] = `${auth.prefix ?? 'Bearer '}${secret}`;
  }
  // type 'none' → no injection.
  return { headers, query };
}

/**
 * Attempt a single OAuth2 access-token refresh for an oauth2 connector, re-storing
 * the new token in the vault. Returns true on success. Best-effort: any missing
 * refresh material or provider config means "no refresh" (returns false). The
 * minted token is never logged or returned to the caller.
 */
async function tryRefreshOauthToken(auth, fetchFn) {
  if (auth?.type !== 'oauth2' || !auth.refresh_token_ref) return false;
  const refreshToken = getSecret(auth.refresh_token_ref);
  if (!refreshToken) return false;
  const provider = resolveProvider(auth.provider, { token_url: auth.token_url });
  if (!provider?.token_url) return false;
  const clientId = auth.client_id_ref ? getSecret(auth.client_id_ref) : null;
  const clientSecret = auth.client_secret_ref ? getSecret(auth.client_secret_ref) : null;
  let result;
  try {
    result = await refreshAccessToken(
      provider,
      { refresh_token: String(refreshToken), client_id: clientId || '', client_secret: clientSecret || '' },
      fetchFn,
    );
  } catch {
    return false;
  }
  if (!result?.ok || !result.access_token) return false;
  try {
    setSecret(auth.access_token_ref, result.access_token, { source: 'oauth_refresh' });
    if (result.refresh_token && auth.refresh_token_ref) {
      setSecret(auth.refresh_token_ref, result.refresh_token, { source: 'oauth_refresh' });
    }
  } catch {
    return false;
  }
  return true;
}

export async function httpApiCall(input = {}, options = {}) {
  const connectorSlug = input.__connector_slug;
  const endpointName = input.__endpoint;
  const toolName = connectorSlug && endpointName ? `${connectorSlug}.${endpointName}` : null;
  const resolved = toolName ? resolveConnectorEndpoint(toolName) : null;
  if (!resolved) {
    return { ok: false, status: 'error', error: 'not_found', message: `Unknown connector endpoint: ${toolName || '(none)'}` };
  }
  const { manifest, endpointDef } = resolved;

  // ── Build path with {param} substitution ──
  let pathStr = endpointDef.path;
  for (const name of pathParamNames(endpointDef.path)) {
    const raw = input[name] ?? input.params?.[name];
    if (raw == null || raw === '') {
      return { ok: false, status: 'error', error: 'missing_path_param', message: `Missing path parameter: ${name}` };
    }
    pathStr = pathStr.replaceAll(`{${name}}`, encodeURIComponent(String(raw)));
  }
  if (/\{[a-z]/i.test(pathStr)) {
    return { ok: false, status: 'error', error: 'missing_path_param', message: `Unresolved path parameter in ${pathStr}` };
  }

  // ── Compose the full URL ──
  let url;
  try {
    url = new URL(manifest.base_url.replace(/\/+$/g, '') + pathStr);
  } catch {
    return { ok: false, status: 'error', error: 'bad_url', message: 'Could not build request URL' };
  }

  // SECURITY: SSRF / private-IP block (reuses web.js net.isIP guard).
  try {
    assertPublicHttpUrl(url.toString());
  } catch (err) {
    return { ok: false, status: 'error', error: 'blocked_host', message: err.message };
  }
  // SECURITY: manifest host allowlist — the resolved host must be explicitly listed.
  const host = url.hostname.toLowerCase();
  if (!Array.isArray(manifest.allowed_hosts) || !manifest.allowed_hosts.includes(host)) {
    return { ok: false, status: 'error', error: 'host_not_allowed', message: `Host ${host} is not in the connector allowlist` };
  }

  // ── Auth (vault secret injection, never echoed) ──
  let authParts;
  try {
    authParts = buildAuth(manifest.auth || {});
  } catch (err) {
    if (err.code === 'secret_missing') {
      return { ok: false, status: 'error', error: 'secret_missing', secret_ref: err.secret_ref, message: err.message };
    }
    throw err;
  }
  for (const [k, v] of Object.entries(authParts.query)) url.searchParams.set(k, v);
  if (input.query && typeof input.query === 'object') {
    for (const [k, v] of Object.entries(input.query)) {
      if (v != null) url.searchParams.set(k, String(v));
    }
  }

  // ── Headers + body ──
  // baseHeaders never carry the secret; authHeaderKeys are injected only when the
  // target host matches the original (auth must never ride a cross-host redirect).
  const baseHeaders = { Accept: 'application/json', ...(manifest.default_headers || {}) };
  let authHeaderKeys = Object.keys(authParts.headers);
  const method = endpointDef.method;
  const hasBody = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
  let bodyStr = null;
  let contentType = null;
  if (hasBody) {
    let body = input.body;
    if (body == null) {
      // Fall back to the non-reserved, non-path-param input keys as the JSON body.
      const params = pathParamNames(endpointDef.path);
      body = {};
      for (const [k, v] of Object.entries(input)) {
        if (RESERVED_KEYS.has(k) || params.includes(k) || k === 'params') continue;
        body[k] = v;
      }
    }
    if (body && typeof body === 'object' && Object.keys(body).length > 0) {
      contentType = (manifest.default_headers || {})['Content-Type'] || 'application/json';
      bodyStr = JSON.stringify(body);
    }
  }

  const buildInit = (forHost) => {
    // Inject the secret-bearing auth headers ONLY when the target host equals the
    // original (validated, allowlisted) host. A redirect to any other host — even
    // another allowlisted host — drops auth so the vault secret can never leak.
    const headers = { ...baseHeaders };
    if (forHost === url.hostname.toLowerCase()) {
      for (const k of authHeaderKeys) headers[k] = authParts.headers[k];
    }
    if (contentType) headers['Content-Type'] = contentType;
    const init = { method, headers, redirect: 'manual' };
    if (bodyStr != null) init.body = bodyStr;
    return init;
  };

  // ── Execute (egress-logged) with MANUAL redirect handling ─────────────────
  // SSRF defense: redirect:'manual' so fetch never auto-follows to an unvalidated
  // host. Every hop is re-checked with assertPublicHttpUrl + the manifest host
  // allowlist before the next request; a redirect outside the allowlist is refused.
  const wrappedFetch = wrapFetch(toolName, options.agent_id ?? null);
  const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]);
  let res;
  // Outer loop allows a SINGLE OAuth2 refresh-on-401 retry. attempt 0 is the
  // normal call; attempt 1 only runs after a successful token refresh.
  for (let attempt = 0; attempt < 2; attempt++) {
    let currentUrl = url;
    let redirectError = null;
    for (let hop = 0; hop < 5; hop++) {
      try {
        res = await wrappedFetch(currentUrl, buildInit(currentUrl.hostname.toLowerCase()));
      } catch (err) {
        return { ok: false, status: 'error', error: 'request_failed', message: String(err?.message || err) };
      }
      if (!REDIRECT_CODES.has(res.status)) break;
      const loc = res.headers.get('location');
      if (!loc) break; // 3xx with no Location — return it as-is below.
      let next;
      try {
        next = new URL(loc, currentUrl);
      } catch {
        return { ok: false, status: 'error', error: 'bad_redirect', message: `Invalid redirect target: ${loc}` };
      }
      try {
        assertPublicHttpUrl(next.toString());
      } catch (err) {
        return { ok: false, status: 'error', error: 'blocked_redirect', message: `Redirect blocked: ${err.message}` };
      }
      if (!manifest.allowed_hosts.includes(next.hostname.toLowerCase())) {
        return { ok: false, status: 'error', error: 'blocked_redirect', message: `Redirect to ${next.hostname} is not in the connector allowlist` };
      }
      currentUrl = next;
      if (hop === 4) {
        redirectError = { ok: false, status: 'error', error: 'too_many_redirects', message: 'Exceeded redirect limit' };
        break;
      }
    }
    if (redirectError) return redirectError;

    // OAuth2 transparent refresh: on a 401, attempt exactly one token refresh,
    // re-store the new token, rebuild auth headers, and retry once.
    if (attempt === 0 && res.status === 401 && (manifest.auth || {}).type === 'oauth2') {
      const refreshed = await tryRefreshOauthToken(manifest.auth, wrappedFetch);
      if (refreshed) {
        try {
          authParts = buildAuth(manifest.auth || {});
          authHeaderKeys = Object.keys(authParts.headers);
        } catch {
          break; // can't rebuild auth — return the 401 as-is.
        }
        continue; // retry with the fresh token.
      }
    }
    break;
  }

  const respContentType = res.headers.get('content-type') || '';
  const reader = res.body?.getReader();
  let bodyText = '';
  if (reader) {
    const chunks = [];
    let total = 0;
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      total += value.byteLength;
      if (total > MAX_RESPONSE_BYTES) { bodyText = '[response truncated: exceeds 512KB]'; break; }
      chunks.push(value);
    }
    if (!bodyText) bodyText = Buffer.concat(chunks).toString('utf8');
  } else {
    bodyText = (await res.text()).slice(0, MAX_RESPONSE_BYTES);
  }

  let data = null;
  if (respContentType.includes('application/json')) {
    try { data = JSON.parse(bodyText); } catch { data = null; }
  }

  if (!res.ok) {
    return {
      ok: false,
      status: 'error',
      error: 'http_error',
      http_status: res.status,
      message: `${method} ${endpointName} → ${res.status}`,
      body: data ?? bodyText.slice(0, 2000),
    };
  }

  return {
    ok: true,
    status: 'complete',
    http_status: res.status,
    content_type: respContentType,
    data: data ?? bodyText.slice(0, MAX_RESPONSE_BYTES),
  };
}
