// lib/connections/manifest-validator.js — pure validator for connection manifests.
//
// A connection manifest is INERT DATA describing how the setup assistant collects
// credentials for a service. This validator enforces the security envelope for the
// new `setup_kind:'oauth2'` shape and the generic `health_check` block, so a
// malformed/malicious manifest cannot embed a raw secret or point a health check
// at a private host.
//
// Load-bearing properties (mirrors connector-validator.js):
//   • OAuth client id/secret + tokens are referenced by *_ref ONLY (vault names),
//     never inline. Any key carrying a raw secret value is rejected.
//   • authorize_url / token_url / health_check.endpoint must be https.
//   • No private/loopback hosts in health_check.endpoint.
//
// Pure ESM — no DB/fs/network imports. Returns { ok, errors, warnings }.

const SECRET_REF_REGEX = /^[A-Z][A-Z0-9_]{1,63}$/;
const VALID_SETUP_KINDS = new Set(['api_key', 'oauth2', 'qr_pair', 'binary_detect']);

// Exact key names that must never hold a raw secret value in the oauth block.
const RAW_SECRET_KEYS = new Set([
  'client_id', 'client_secret', 'access_token', 'refresh_token', 'token', 'secret', 'password', 'api_key',
]);

const OAUTH_REF_FIELDS = ['client_id_ref', 'client_secret_ref', 'access_token_ref', 'refresh_token_ref'];

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

function isHttpsUrl(value) {
  if (typeof value !== 'string') return false;
  try {
    return new URL(value).protocol === 'https:';
  } catch {
    return false;
  }
}

function isPrivateOrLocalHost(host) {
  if (!host) return false;
  const h = String(host).toLowerCase().replace(/^\[|\]$/g, '');
  if (h === 'localhost' || h === '::1' || h === '0.0.0.0') return true;
  if (h.endsWith('.localhost') || h.endsWith('.local')) return true;
  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;
}

function validateOauthBlock(oauth, errors) {
  if (!isPlainObject(oauth)) {
    errors.push('oauth: required (plain object) for setup_kind oauth2');
    return;
  }

  // SECURITY: reject any raw-secret key (must use *_ref instead).
  for (const key of Object.keys(oauth)) {
    if (RAW_SECRET_KEYS.has(key.toLowerCase())) {
      errors.push(`oauth: raw secrets are not allowed; reference by *_ref only (found '${key}')`);
    }
  }

  if (!oauth.provider || typeof oauth.provider !== 'string') {
    errors.push('oauth.provider: required (string)');
  }

  // authorize_url / token_url may be omitted IF the provider is catalogued; the
  // validator can't see the registry, so when present they must be https.
  if (oauth.authorize_url != null && !isHttpsUrl(oauth.authorize_url)) {
    errors.push('oauth.authorize_url: must be https');
  }
  if (oauth.token_url != null && !isHttpsUrl(oauth.token_url)) {
    errors.push('oauth.token_url: must be https');
  }

  if (oauth.scopes != null && !Array.isArray(oauth.scopes)) {
    errors.push('oauth.scopes: must be an array of strings');
  }

  // client_id_ref is required so the operator's app credentials resolve from the
  // vault; access_token_ref is required so the minted token has a home.
  if (!oauth.client_id_ref || typeof oauth.client_id_ref !== 'string') {
    errors.push('oauth.client_id_ref: required (vault ref, e.g. META_CLIENT_ID)');
  }
  if (!oauth.access_token_ref || typeof oauth.access_token_ref !== 'string') {
    errors.push('oauth.access_token_ref: required (vault ref, e.g. META_ACCESS_TOKEN)');
  }

  for (const field of OAUTH_REF_FIELDS) {
    const val = oauth[field];
    if (val == null) continue;
    if (typeof val !== 'string' || !SECRET_REF_REGEX.test(val)) {
      errors.push(`oauth.${field}: must match ${SECRET_REF_REGEX} (uppercase vault ref)`);
    }
  }
}

function validateHealthCheck(hc, errors) {
  if (!isPlainObject(hc)) {
    errors.push('health_check: must be a plain object');
    return;
  }
  if (!hc.endpoint || !isHttpsUrl(hc.endpoint)) {
    errors.push('health_check.endpoint: required (https URL)');
  } else {
    let host;
    try { host = new URL(hc.endpoint).hostname; } catch { host = null; }
    if (host && isPrivateOrLocalHost(host)) {
      errors.push(`health_check.endpoint: private/local hosts are not allowed ('${host}')`);
    }
  }
  if (hc.method != null && !/^(GET|POST|PUT|PATCH|DELETE|HEAD)$/.test(String(hc.method))) {
    errors.push('health_check.method: must be a valid HTTP method (uppercase)');
  }
  if (hc.expect_status != null && !Number.isInteger(hc.expect_status)) {
    errors.push('health_check.expect_status: must be an integer status code');
  }
}

/**
 * Validate a connection manifest. Strict for the oauth2 + health_check additions;
 * permissive (warnings only) for the legacy api_key/qr_pair/binary_detect kinds so
 * existing seeds keep validating.
 *
 * @param {object} manifest
 * @returns {{ ok: boolean, errors: string[], warnings: string[] }}
 */
export function validateConnectionManifest(manifest) {
  const errors = [];
  const warnings = [];

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

  const setupKind = manifest.setup_kind || 'api_key';
  if (!VALID_SETUP_KINDS.has(setupKind)) {
    errors.push(`setup_kind: must be one of ${[...VALID_SETUP_KINDS].join(', ')}`);
  }

  if (setupKind === 'oauth2') {
    validateOauthBlock(manifest.oauth, errors);
  }

  if (manifest.health_check != null) {
    validateHealthCheck(manifest.health_check, errors);
  }

  if (manifest.unlocks_tool_slugs != null && !Array.isArray(manifest.unlocks_tool_slugs)) {
    errors.push('unlocks_tool_slugs: must be an array of tool slug strings');
  }

  return { ok: errors.length === 0, errors, warnings };
}

export { VALID_SETUP_KINDS };
