// lib/tools/connectors/connector-validator.js — strict, pure validator for
// connector manifests.
//
// A connector manifest is INERT DATA. It is never "executed" by this validator
// — it describes an HTTP endpoint surface that a generic HTTP executor can
// safely call at runtime. The validator's job is to enforce the security
// envelope so that a malformed or malicious manifest cannot:
//   • point at private/local network addresses
//   • embed raw secrets in the manifest body (all credentials must be
//     referenced by name, resolved at runtime by the secrets vault)
//   • self-declare write endpoints as read-only (risk ceiling enforcement)
//   • accept non-HTTPS base URLs (SSRF / plaintext-secret risk)
//
// Load-bearing security properties
// ─────────────────────────────────
//  1. Host allowlist — base_url host must appear in allowed_hosts; private and
//     loopback addresses are rejected unconditionally.
//  2. Secret-by-reference — auth fields may only reference secrets by name
//     (via *_ref fields). Any key that could carry a raw secret value is
//     rejected with a hard error.
//  3. Risk ceiling — POST/PUT/PATCH/DELETE endpoints cannot be declared 'low';
//     clamped to 'medium' with a warning (not a hard error) so agent authors
//     get feedback without breaking existing specs that omit the field.
//  4. HTTPS-only — base_url must use the https: protocol.
//
// Contract:
//   validateConnectorManifest(manifest, opts?) → { ok, errors, warnings, normalized }
//
// On ok:false, errors[] is populated and normalized is null.
// On ok:true, normalized is a defensively-cloned canonical form (hosts
// lowercased, endpoints have explicit risk, default_headers defaulted to {}).
// Warnings may appear in either branch — they never block.
//
// This file has NO imports from DB, fs, or any runtime system. Pure ESM.

// ─── Exported constants ──────────────────────────────────────────────────────

export const CONNECTOR_SLUG_REGEX = /^[a-z][a-z0-9-]{2,63}$/;
export const MAX_CONNECTOR_BYTES = 64 * 1024;
export const VALID_AUTH_TYPES = new Set(['bearer', 'api_key', 'basic', 'oauth2', 'none']);

// Ordered low→high. 'blocked' is never authorable and is intentionally absent.
export const RISK_ORDER = ['low', 'medium', 'high', 'irreversible_external'];

const VALID_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const ENDPOINT_KEY_REGEX = /^[a-z][a-z0-9_]{0,63}$/;
const PATH_PARAM_REGEX = /\{[a-z][a-z0-9_]*\}/g;
const SECRET_REF_REGEX = /^[A-Z][A-Z0-9_]{1,63}$/;
const MAX_ENDPOINTS = 50;

// Raw-secret field names that are forbidden directly in auth (case-insensitive
// comparison is applied at runtime).
const RAW_SECRET_KEYS = new Set([
  'token', 'key', 'secret', 'password', 'value', 'api_key', 'bearer', 'credentials',
  // oauth2 raw-token names — must be referenced via *_ref, never inline.
  'access_token', 'refresh_token', 'client_id', 'client_secret',
]);

// ─── Internal helpers ────────────────────────────────────────────────────────

function pushErr(errors, p, reason) {
  errors.push(`${p}: ${reason}`);
}

function isPlainObject(v) {
  return v !== null && typeof v === 'object' && !Array.isArray(v);
}

/** Returns the numeric rank of a risk tier, or -1 if unrecognised. */
function riskRank(tier) {
  return RISK_ORDER.indexOf(tier);
}

/**
 * Reject private/loopback/link-local hostnames and IP literals.
 * Covers the most common SSRF vectors — not a full RFC-exhaustive check, but
 * sufficient for the threat model (manifest authors are authenticated users,
 * not anonymous attackers; this is a defence-in-depth layer).
 */
function isPrivateOrLocalHost(host) {
  if (!host) return false;
  const h = String(host).toLowerCase().replace(/^\[|\]$/g, '');

  // Exact loopback / link-local names
  if (h === 'localhost' || h === '::1') return true;
  if (h.endsWith('.localhost') || h.endsWith('.local')) return true;

  // IPv6 literals only (contain a colon). fc00::/7 (fc, fd), fe80::/10.
  // Gated on the colon so real hostnames like 'fcm.googleapis.com' are NOT
  // rejected — only genuine IPv6 literals trip these.
  if (h.includes(':')) {
    if (h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80:')) return true;
    if (h === '::') return true;
    return false;
  }

  // IPv4 private / loopback / link-local — only when the host is a dotted-quad
  // literal. A real hostname (e.g. '10minutemail.com') is not an IP and is left
  // alone here; the runtime executor's net.isIP-based guard is the hard block.
  const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
  if (!ipv4) return false;
  const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
  if (a === 10 || a === 127 || a === 0) return true;
  if (a === 169 && b === 254) return true;
  if (a === 192 && b === 168) return true;
  if (a === 172 && b >= 16 && b <= 31) return true;
  return false;
}

/** Return placeholder param names embedded in a path template string. */
function extractPathParams(path) {
  const params = [];
  let m;
  const re = /\{([^}]+)\}/g;
  while ((m = re.exec(path)) !== null) {
    params.push(m[1]);
  }
  return params;
}

// ─── Section validators ──────────────────────────────────────────────────────

function validateAuth(auth, errors) {
  if (!isPlainObject(auth)) {
    pushErr(errors, 'auth', 'required (plain object)');
    return;
  }

  if (!auth.type || !VALID_AUTH_TYPES.has(auth.type)) {
    pushErr(
      errors,
      'auth.type',
      `must be one of ${[...VALID_AUTH_TYPES].join(', ')}`
    );
  }

  // SECURITY: reject any key that could hold a raw secret value.
  for (const key of Object.keys(auth)) {
    if (RAW_SECRET_KEYS.has(key.toLowerCase())) {
      pushErr(
        errors,
        'auth',
        `raw secrets are not allowed; reference by *_ref only (found field '${key}')`
      );
    }
  }

  // Type-specific ref requirements
  const type = auth.type;
  if (type === 'bearer' || type === 'api_key') {
    if (!auth.secret_ref || typeof auth.secret_ref !== 'string') {
      pushErr(errors, 'auth.secret_ref', 'required for bearer/api_key auth (string)');
    } else if (!SECRET_REF_REGEX.test(auth.secret_ref)) {
      pushErr(
        errors,
        'auth.secret_ref',
        `must match ${SECRET_REF_REGEX} (uppercase, starts with letter, underscores/digits, max 64 chars)`
      );
    }
  }

  if (type === 'basic') {
    if (!auth.username_ref || typeof auth.username_ref !== 'string') {
      pushErr(errors, 'auth.username_ref', 'required for basic auth (string)');
    } else if (!SECRET_REF_REGEX.test(auth.username_ref)) {
      pushErr(errors, 'auth.username_ref', `must match ${SECRET_REF_REGEX}`);
    }

    if (!auth.password_ref || typeof auth.password_ref !== 'string') {
      pushErr(errors, 'auth.password_ref', 'required for basic auth (string)');
    } else if (!SECRET_REF_REGEX.test(auth.password_ref)) {
      pushErr(errors, 'auth.password_ref', `must match ${SECRET_REF_REGEX}`);
    }
  }

  if (type === 'oauth2') {
    // The access token is referenced from the vault, never inline. Refresh fields
    // are optional (a connector can carry a long-lived token with no refresh).
    if (!auth.access_token_ref || typeof auth.access_token_ref !== 'string') {
      pushErr(errors, 'auth.access_token_ref', 'required for oauth2 auth (vault ref)');
    } else if (!SECRET_REF_REGEX.test(auth.access_token_ref)) {
      pushErr(errors, 'auth.access_token_ref', `must match ${SECRET_REF_REGEX}`);
    }
    for (const field of ['refresh_token_ref', 'client_id_ref', 'client_secret_ref']) {
      if (auth[field] == null) continue;
      if (typeof auth[field] !== 'string' || !SECRET_REF_REGEX.test(auth[field])) {
        pushErr(errors, `auth.${field}`, `must match ${SECRET_REF_REGEX}`);
      }
    }
    if (auth.token_url != null) {
      let ok = false;
      try { ok = new URL(auth.token_url).protocol === 'https:'; } catch { ok = false; }
      if (!ok) pushErr(errors, 'auth.token_url', 'must be a valid https URL');
    }
  }
}

function validateDefaultHeaders(headers, errors) {
  if (!isPlainObject(headers)) {
    pushErr(errors, 'default_headers', 'must be a plain object');
    return;
  }
  for (const [name, value] of Object.entries(headers)) {
    if (typeof value !== 'string') {
      pushErr(errors, `default_headers.${name}`, 'header value must be a string');
    }
    if (/authorization/i.test(name)) {
      pushErr(
        errors,
        'default_headers',
        `must not set Authorization (use auth); found header '${name}'`
      );
    }
  }
}

/**
 * Validate a single endpoint definition.
 *
 * Returns the clamped effective risk string plus a boolean indicating whether
 * a clamp warning should be issued (risk was declared 'low' on a write method).
 * Returns null if the endpoint is structurally invalid.
 */
function validateEndpoint(endpointDef, errors, prefix) {
  if (!isPlainObject(endpointDef)) {
    pushErr(errors, prefix, 'must be a plain object');
    return null;
  }

  // method
  if (!endpointDef.method || !VALID_METHODS.has(endpointDef.method)) {
    pushErr(
      errors,
      `${prefix}.method`,
      `must be one of ${[...VALID_METHODS].join(', ')} (uppercase)`
    );
  }

  // path
  if (!endpointDef.path || typeof endpointDef.path !== 'string') {
    pushErr(errors, `${prefix}.path`, 'required (string starting with /)');
  } else if (!endpointDef.path.startsWith('/')) {
    pushErr(errors, `${prefix}.path`, "must start with '/'");
  } else {
    // Validate path param placeholders
    const params = extractPathParams(endpointDef.path);
    for (const param of params) {
      if (!/^[a-z][a-z0-9_]*$/.test(param)) {
        pushErr(
          errors,
          `${prefix}.path`,
          `placeholder '{${param}}' must match /^[a-z][a-z0-9_]*$/`
        );
      }
    }
  }

  // description — optional string
  if (endpointDef.description != null && typeof endpointDef.description !== 'string') {
    pushErr(errors, `${prefix}.description`, 'must be a string');
  }

  // input_schema — optional plain object
  if (endpointDef.input_schema != null && !isPlainObject(endpointDef.input_schema)) {
    pushErr(errors, `${prefix}.input_schema`, 'must be a plain object');
  }

  // risk — validate then apply ceiling
  const method = endpointDef.method;
  const isWrite = WRITE_METHODS.has(method);
  let declaredRisk = endpointDef.risk;
  let clampWarning = false;

  if (declaredRisk != null) {
    if (!RISK_ORDER.includes(declaredRisk)) {
      pushErr(errors, `${prefix}.risk`, `must be one of ${RISK_ORDER.join(', ')}`);
      // Fall through to default
      declaredRisk = undefined;
    }
  }

  let effectiveRisk;
  if (declaredRisk == null) {
    // Default: GET → 'low', write methods → 'medium'
    effectiveRisk = isWrite ? 'medium' : 'low';
  } else if (isWrite && riskRank(declaredRisk) < riskRank('medium')) {
    // CLAMP: write method declared below 'medium' → force to 'medium'
    effectiveRisk = 'medium';
    clampWarning = true;
  } else {
    effectiveRisk = declaredRisk;
  }

  return { effectiveRisk, clampWarning };
}

// ─── Main export ─────────────────────────────────────────────────────────────

export function validateConnectorManifest(manifest, opts = {}) {
  const errors = [];
  const warnings = [];

  // ── Size cap ──
  let serialized;
  try {
    serialized = JSON.stringify(manifest);
  } catch {
    return { ok: false, errors: ['manifest: not JSON-serialisable'], warnings: [], normalized: null };
  }
  if (!serialized || serialized === 'undefined') {
    return { ok: false, errors: ['manifest: required'], warnings: [], normalized: null };
  }
  if (Buffer.byteLength(serialized, 'utf8') > MAX_CONNECTOR_BYTES) {
    return {
      ok: false,
      errors: [
        `manifest: serialized length ${Buffer.byteLength(serialized, 'utf8')} bytes exceeds ${MAX_CONNECTOR_BYTES}-byte cap`,
      ],
      warnings: [],
      normalized: null,
    };
  }

  if (!isPlainObject(manifest)) {
    return { ok: false, errors: ['manifest: must be a plain object'], warnings: [], normalized: null };
  }

  // ── slug ──
  if (!manifest.slug || typeof manifest.slug !== 'string') {
    pushErr(errors, 'slug', 'required (string)');
  } else if (!CONNECTOR_SLUG_REGEX.test(manifest.slug)) {
    pushErr(
      errors,
      'slug',
      `does not match ${CONNECTOR_SLUG_REGEX} (lowercase, starts with letter, 3-64 chars, hyphens allowed)`
    );
  }

  // ── title (optional) ──
  if (manifest.title != null && typeof manifest.title !== 'string') {
    pushErr(errors, 'title', 'must be a string');
  }

  // ── base_url — HTTPS only ──
  let baseHost = null;
  if (!manifest.base_url || typeof manifest.base_url !== 'string') {
    pushErr(errors, 'base_url', 'required (string)');
  } else {
    let parsedUrl;
    try {
      parsedUrl = new URL(manifest.base_url);
    } catch {
      pushErr(errors, 'base_url', 'must be a valid URL');
    }
    if (parsedUrl) {
      if (parsedUrl.protocol !== 'https:') {
        pushErr(errors, 'base_url', 'must be https');
      }
      baseHost = parsedUrl.hostname.toLowerCase();
    }
  }

  // ── allowed_hosts ──
  let normalizedHosts = [];
  if (!Array.isArray(manifest.allowed_hosts) || manifest.allowed_hosts.length === 0) {
    pushErr(errors, 'allowed_hosts', 'required (non-empty array of host strings)');
  } else {
    normalizedHosts = manifest.allowed_hosts.map((h) =>
      typeof h === 'string' ? h.toLowerCase() : h
    );

    // Each host must be a non-private string
    normalizedHosts.forEach((host, i) => {
      if (typeof host !== 'string' || !host) {
        pushErr(errors, `allowed_hosts[${i}]`, 'must be a non-empty string');
        return;
      }
      if (isPrivateOrLocalHost(host)) {
        pushErr(errors, `allowed_hosts[${i}]`, `private/local hosts are not allowed ('${host}')`);
      }
    });

    // base_url host must be in the allowlist
    if (baseHost && !normalizedHosts.includes(baseHost)) {
      pushErr(errors, 'allowed_hosts', `must include base_url host '${baseHost}'`);
    }
  }

  // ── auth ──
  validateAuth(manifest.auth, errors);

  // ── default_headers (optional) ──
  if (manifest.default_headers != null) {
    validateDefaultHeaders(manifest.default_headers, errors);
  }

  // ── endpoints ──
  if (!isPlainObject(manifest.endpoints) || Object.keys(manifest.endpoints).length === 0) {
    pushErr(errors, 'endpoints', 'required (non-empty plain object)');
    return { ok: false, errors, warnings, normalized: null };
  }

  const endpointEntries = Object.entries(manifest.endpoints);
  if (endpointEntries.length > MAX_ENDPOINTS) {
    pushErr(errors, 'endpoints', `exceeds ${MAX_ENDPOINTS} endpoint cap`);
    return { ok: false, errors, warnings, normalized: null };
  }

  const normalizedEndpoints = {};
  for (const [name, def] of endpointEntries) {
    const prefix = `endpoints.${name}`;

    if (!ENDPOINT_KEY_REGEX.test(name)) {
      pushErr(errors, prefix, `endpoint name must match ${ENDPOINT_KEY_REGEX}`);
    }

    const result = validateEndpoint(def, errors, prefix);
    if (result) {
      const { effectiveRisk, clampWarning } = result;
      if (clampWarning) {
        warnings.push(`${prefix}.risk: clamped low→medium for write method`);
      }
      normalizedEndpoints[name] = {
        method: def.method,
        path: def.path ?? null,
        risk: effectiveRisk,
        input_schema: def.input_schema != null ? structuredClone(def.input_schema) : null,
        description: def.description ?? null,
      };
    }
  }

  if (errors.length > 0) {
    return { ok: false, errors, warnings, normalized: null };
  }

  // ── Normalize auth (strip raw-secret keys — belt and suspenders) ──
  const normalizedAuth = {};
  const allowedAuthKeys = new Set([
    'type', 'header', 'prefix', 'query_param',
    'secret_ref', 'username_ref', 'password_ref',
    // oauth2: token reference + refresh material (all *_ref, never raw)
    'access_token_ref', 'refresh_token_ref', 'client_id_ref', 'client_secret_ref',
    'token_url', 'provider',
  ]);
  for (const [k, v] of Object.entries(manifest.auth)) {
    if (allowedAuthKeys.has(k)) normalizedAuth[k] = v;
  }

  const normalized = {
    slug: manifest.slug,
    title: manifest.title ?? null,
    base_url: manifest.base_url,
    allowed_hosts: normalizedHosts,
    auth: normalizedAuth,
    default_headers: manifest.default_headers != null
      ? structuredClone(manifest.default_headers)
      : {},
    endpoints: normalizedEndpoints,
  };

  return { ok: true, errors: [], warnings, normalized };
}
