/**
 * Connector test runner — extracted from the /api/connectors/:id/test
 * route so both that HTTP endpoint AND server-side callers (e.g. the
 * Login Status panel) can run probes without a self-fetch dance.
 *
 * Self-fetching the route from inside the server hits the auth
 * middleware (no session cookie → 401), making every probe look
 * "unauthorized" regardless of the real connector state. Direct
 * function calls bypass that.
 */

import {
  getConnector,
  getConnectorEntries,
  getInstalledConnector,
} from './registry';
import { expandSettingsTokens, expandAllTokens } from '../plugins/templates';
import { bridgeRpc } from '../chat/bridge-client';
import { applyAuth } from '../chat/protocols/http';
import type { ConnectorDefinition, ConnectorTest, HttpRequestSpec } from './types';

const DEFAULT_TIMEOUT_MS = 15_000;
const MAX_BODY_PREVIEW = 1024;

export interface TestResult {
  ok: boolean;
  status?: number;
  message?: string;
  error?: string;
  duration_ms?: number;
  body_preview?: string;
  /** Final landed URL for browser probes — helps diagnose redirects. */
  url?: string;
}

function expandString(s: string, settings: Record<string, unknown>): string {
  return expandAllTokens(s, settings as Record<string, any>, {});
}

function buildUrl(spec: HttpRequestSpec, settings: Record<string, unknown>): string {
  let url = expandString(spec.url, settings);
  if (spec.query) {
    const u = new URL(url);
    for (const [k, raw] of Object.entries(spec.query)) {
      u.searchParams.set(k, expandString(String(raw), settings));
    }
    url = u.toString();
  }
  return url;
}

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

function buildBody(
  spec: HttpRequestSpec,
  settings: Record<string, unknown>,
): { body?: string; contentType?: string } {
  if (spec.body == null) return {};
  if (typeof spec.body === 'string') {
    return { body: expandString(spec.body, settings) };
  }
  const out: Record<string, unknown> = {};
  for (const [k, v] of Object.entries(spec.body)) {
    out[k] = typeof v === 'string' ? expandString(v, settings) : v;
  }
  return { body: JSON.stringify(out), contentType: 'application/json' };
}

function renderTemplate(template: string, body: unknown): string {
  return template.replace(/\{\{([^{}]+)\}\}/g, (_match, expr) => {
    const path = String(expr).trim().split('.').filter(Boolean);
    let cur: any = body;
    for (const p of path) {
      if (cur && typeof cur === 'object' && p in cur) cur = cur[p];
      else return '?';
    }
    if (cur == null) return '?';
    return typeof cur === 'string' ? cur : JSON.stringify(cur);
  });
}

async function runHttpProbe(
  test: ConnectorTest,
  settings: Record<string, unknown>,
  def: ConnectorDefinition,
): Promise<TestResult> {
  const spec = test.request;
  if (!spec?.url) return { ok: false, error: 'test.request.url is required for http probe' };

  // Multi-instance overlay: first instance only (matches tool-dispatcher).
  let effectiveSettings = settings as Record<string, any>;
  let instances = effectiveSettings?.instances;
  if (typeof instances === 'string') {
    try { instances = JSON.parse(instances); } catch { instances = null; }
  }
  if (
    Array.isArray(instances) &&
    instances.length > 0 &&
    instances.every((i: any) => i && typeof i === 'object' && typeof i.name === 'string')
  ) {
    effectiveSettings = { ...effectiveSettings, ...instances[0] };
  }

  const method = (spec.method || 'GET').toUpperCase();
  let url = buildUrl(spec, effectiveSettings);
  const headers = buildHeaders(spec, effectiveSettings);
  const { body, contentType } = buildBody(spec, effectiveSettings);
  if (body != null && contentType && !headers.has('content-type')) {
    headers.set('content-type', contentType);
  }
  try {
    url = await applyAuth(url, headers, def.auth, effectiveSettings, {}, def.http?.verify_tls);
  } catch (e) {
    return { ok: false, error: `auth setup failed: ${(e as Error).message}` };
  }

  const timeoutMs = test.timeout_ms || DEFAULT_TIMEOUT_MS;
  const okStatus = test.ok_status?.length ? test.ok_status : [200];

  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
  const t0 = Date.now();
  let res: Response;
  try {
    res = await fetch(url, { method, headers, body, signal: ctrl.signal });
  } catch (e) {
    clearTimeout(timer);
    const err = e as Error & { cause?: unknown };
    const cause = err.cause instanceof Error ? `: ${err.cause.message}` : '';
    return {
      ok: false,
      error: `request failed: ${err.message}${cause}`,
      duration_ms: Date.now() - t0,
    };
  }
  clearTimeout(timer);

  const duration = Date.now() - t0;
  const text = await res.text().catch(() => '');
  const preview = text.length > MAX_BODY_PREVIEW ? text.slice(0, MAX_BODY_PREVIEW) + '…' : text;

  if (!okStatus.includes(res.status)) {
    let errMsg = `HTTP ${res.status} ${res.statusText}`;
    try {
      const j = JSON.parse(text);
      if (typeof j?.error === 'string') errMsg += `: ${j.error}`;
      else if (typeof j?.message === 'string') errMsg += `: ${j.message}`;
      else if (typeof j?.error_description === 'string') errMsg += `: ${j.error_description}`;
    } catch {}
    return {
      ok: false,
      status: res.status,
      error: errMsg,
      duration_ms: duration,
      body_preview: preview,
    };
  }

  // body_match — gate the test on response body content (e.g. version
  // check). Applies BEFORE ok_template rendering.
  if (test.body_match) {
    let pattern: RegExp;
    try {
      pattern = new RegExp(test.body_match);
    } catch (e) {
      return {
        ok: false,
        status: res.status,
        error: `invalid body_match regex "${test.body_match}": ${(e as Error).message}`,
        duration_ms: duration,
        body_preview: preview,
      };
    }
    if (!pattern.test(text)) {
      const errMsg = test.body_match_error
        || `body did not match expected pattern /${test.body_match}/ — got: ${preview.slice(0, 100)}`;
      return {
        ok: false,
        status: res.status,
        error: errMsg,
        duration_ms: duration,
        body_preview: preview,
      };
    }
  }

  let parsedBody: unknown = null;
  try { parsedBody = JSON.parse(text); } catch {}
  const message = test.ok_template
    ? renderTemplate(test.ok_template, parsedBody)
    : `OK (HTTP ${res.status})`;
  return { ok: true, status: res.status, message, duration_ms: duration };
}

async function runBrowserProbe(
  def: ConnectorDefinition,
  settings: Record<string, unknown>,
): Promise<TestResult> {
  const entries = getConnectorEntries(def);
  const entry = entries[0];
  const hostMatch = def.host_match || entry?.host_match;
  const loginRedirect = def.login_redirect || entry?.login_redirect;
  if (!hostMatch) {
    return { ok: false, error: 'browser probe requires host_match on the manifest' };
  }
  const expandedHost = expandSettingsTokens(hostMatch, settings as any);
  const expandedLoginRedirect = loginRedirect
    ? expandSettingsTokens(loginRedirect, settings as any)
    : undefined;

  const t0 = Date.now();
  let value: unknown;
  try {
    value = await bridgeRpc('connector.probe', {
      pluginId: def.id,
      host_match: expandedHost,
      login_redirect: expandedLoginRedirect,
      // Optional DOM-based auth check for sites that don't redirect to
      // /login when unauthed (e.g. PMDB renders the login form inline).
      // Extension v0.2.15+ runs querySelector after navigation; match →
      // probe fails with "login required". Older extensions ignore it
      // and fall back to URL-only checking.
      auth_required_selector: def.test?.auth_required_selector || undefined,
      runner: def.runner || entry?.runner || 'main',
      timeout_ms: def.test?.timeout_ms || 30_000,
    });
  } catch (e) {
    const raw = (e as Error).message || String(e);
    let friendly = raw;
    if (raw.includes('unknown method: connector.probe')) {
      friendly =
        'Your Forge browser extension is out of date — it doesn\'t know how to run browser probes yet. ' +
        'Rebuild the extension (pnpm ext in forge-browser-extension), then chrome://extensions → Reload, and try Test again.';
    } else if (raw.includes('bridge') && raw.includes('unreachable')) {
      friendly =
        'Forge browser bridge is unreachable on port 8407. Restart Forge (forge server restart) or check that the browser-bridge standalone is running.';
    } else if (raw.includes('no paired extensions') || raw.includes('no connected')) {
      friendly =
        'Forge browser extension not connected. Install the extension from forge-browser-extension/dist, pin it, and sign in with your Forge URL + admin password.';
    }
    return { ok: false, error: friendly, duration_ms: Date.now() - t0 };
  }
  const r = (value || {}) as { ok?: boolean; url?: string; error?: string };
  // A bare `ok: true` is not enough: an older extension navigates to
  // host_match, hits a DNS / TCP failure (Chrome shows chrome-error://),
  // then reports back without realising the request actually failed. We
  // require the final URL to (a) exist and (b) not be a Chrome error
  // page before treating the probe as successful.
  const failedNetwork = !r.url || r.url.startsWith('chrome-error://') || r.url.startsWith('about:');
  const succeeded = !!r.ok && !failedNetwork;
  return {
    ok: succeeded,
    message: succeeded ? `Session active${r.url ? ` · ${r.url}` : ''}` : undefined,
    error: succeeded
      ? undefined
      : (r.error
        || (failedNetwork
          ? `Network unreachable — extension reached ${r.url || '(no url)'}. VPN / hostname / firewall?`
          : 'login required')),
    url: r.url,
    duration_ms: Date.now() - t0,
  };
}

/**
 * Run a browser probe against an ad-hoc URL — used by the Login Status
 * panel's IdP rows (no connector backing them). Reuses the same
 * `connector.probe` bridge RPC as runBrowserProbe — the extension
 * opens host_match in a tab, waits for navigation, returns landed URL.
 * If the landed URL host matches `expected_host`, the user is still
 * signed in. If it redirected to a login page (different host or path),
 * not signed in.
 */
export async function runBrowserUrlProbe(opts: {
  id: string;            // identifier for telemetry, e.g. 'idp:fac.corp.fortinet.com'
  host: string;          // expected host (e.g. 'fac.corp.fortinet.com')
  probe_url: string;     // URL to navigate to (e.g. 'https://fac.corp.fortinet.com/saml-idp/portal/')
  timeout_ms?: number;
}): Promise<TestResult> {
  const t0 = Date.now();
  let value: unknown;
  try {
    value = await bridgeRpc('connector.probe', {
      pluginId: opts.id,
      host_match: opts.probe_url,    // extension navigates to this URL directly
      runner: 'main',
      timeout_ms: opts.timeout_ms || 15_000,
    });
  } catch (e) {
    return { ok: false, error: (e as Error).message || 'probe error', duration_ms: Date.now() - t0 };
  }
  const r = (value || {}) as { ok?: boolean; url?: string; error?: string };
  const landed = r.url || '';
  const failedNetwork = !landed || landed.startsWith('chrome-error://') || landed.startsWith('about:');
  // Pass if landed host matches expected. A redirect to a different host
  // (e.g. fac.corp.fortinet.com → ms-login.fortinet.com when SSO expired)
  // means the user needs to re-authenticate.
  let onExpected = false;
  try { onExpected = !failedNetwork && new URL(landed).hostname.toLowerCase() === opts.host.toLowerCase(); } catch { /* bad url */ }
  return {
    ok: onExpected,
    message: onExpected ? `Session active · ${landed}` : undefined,
    error: onExpected
      ? undefined
      : (failedNetwork
        ? `Network unreachable — ${landed || '(no url)'}. VPN / hostname / firewall?`
        : `Not signed in — redirected to ${landed}`),
    url: landed,
    duration_ms: Date.now() - t0,
  };
}

/**
 * Run a connector's `test:` probe. Returns a structured result. The HTTP
 * route + Login Status panel both call this — they share the same
 * semantics for free.
 */
export async function runConnectorTest(id: string): Promise<TestResult & { code?: number }> {
  const def = getConnector(id);
  if (!def) return { ok: false, error: 'connector not found', code: 404 };
  if (!def.test) return { ok: false, error: 'connector has no test block', code: 400 };
  const inst = getInstalledConnector(id);
  if (!inst) return { ok: false, error: 'connector not installed', code: 400 };

  const probe = def.test.probe || 'http';
  return probe === 'browser'
    ? await runBrowserProbe(def, inst.config)
    : await runHttpProbe(def.test, inst.config, def);
}
