// lib/hail/validators.js — strict, pure validators for HAIL structured artifacts.
//
// Mirrors the contract of lib/process/spec-validator.js:
//   validateX(obj, 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.
//
// These validators gate the machine-readable HAIL outputs BEFORE they can drive
// any madarX OS configuration. They never touch the DB or fs. The setup-package
// validator is the load-bearing one: it is the single shape gate between a
// HAIL blueprint and the runtime apply path, so it stays strict.

import {
  SENSITIVITY_SET,
  GOVERNANCE_RISK_SET,
  AGENT_AUTHORITY_SET,
  REVIEWER_ROLE_SET,
  SETUP_ITEM_TYPE_SET,
  CONFIDENCE_SET,
  MAX_SETUP_PACKAGE_BYTES,
  MAX_SETUP_ITEMS,
} from './constants.js';

const SLUG_REGEX = /^[a-z][a-z0-9-]{2,63}$/;

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

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

function isNonEmptyString(v) {
  return typeof v === 'string' && v.trim().length > 0;
}

function fail(errors, warnings = []) {
  return { ok: false, errors, warnings, normalized: null };
}

// ── Sensitivity classification ───────────────────────────────────────────────

// Every captured answer must carry a sensitivity class at capture. Returns true
// only for a recognised class. `refused` is valid and signals "do not store".
export function isValidSensitivity(value) {
  return typeof value === 'string' && SENSITIVITY_SET.has(value);
}

// ── Diagnostic payload (hail_diagnostic_v1) ──────────────────────────────────

// Validates the structured output of the diagnostic agent before it is
// persisted into hail_operating_profiles / hail_roles / hail_role_boundaries /
// hail_frictions. Lenient where the agent may legitimately omit detail (warns),
// strict on shape (the arrays must be arrays; the profile must be an object).
export function validateDiagnosticPayload(payload) {
  const errors = [];
  const warnings = [];
  if (!isPlainObject(payload)) return fail(['diagnostic: must be an object']);

  if (!isPlainObject(payload.operating_profile)) {
    pushErr(errors, 'operating_profile', 'required (object)');
  }
  for (const key of ['roles', 'role_boundaries', 'frictions', 'confidence_notes']) {
    if (payload[key] != null && !Array.isArray(payload[key])) {
      pushErr(errors, key, 'must be an array when present');
    }
  }
  if (errors.length) return fail(errors, warnings);

  const roles = Array.isArray(payload.roles) ? payload.roles : [];
  roles.forEach((r, i) => {
    if (!isPlainObject(r)) { pushErr(errors, `roles[${i}]`, 'must be an object'); return; }
    if (!isNonEmptyString(r.slug)) pushErr(errors, `roles[${i}].slug`, 'required (string)');
    if (!isNonEmptyString(r.name)) pushErr(errors, `roles[${i}].name`, 'required (string)');
  });
  const boundaries = Array.isArray(payload.role_boundaries) ? payload.role_boundaries : [];
  boundaries.forEach((b, i) => {
    if (!isPlainObject(b)) { pushErr(errors, `role_boundaries[${i}]`, 'must be an object'); return; }
    if (!isNonEmptyString(b.role_a_slug)) pushErr(errors, `role_boundaries[${i}].role_a_slug`, 'required (string)');
    if (!isNonEmptyString(b.boundary_kind)) pushErr(errors, `role_boundaries[${i}].boundary_kind`, 'required (string)');
  });
  const confNotes = Array.isArray(payload.confidence_notes) ? payload.confidence_notes : [];
  confNotes.forEach((n, i) => {
    if (isPlainObject(n) && n.confidence != null && !CONFIDENCE_SET.has(n.confidence)) {
      warnings.push(`confidence_notes[${i}]: unknown confidence "${n.confidence}"`);
    }
  });
  if (!isNonEmptyString(payload.summary)) warnings.push('summary: empty — diagnostic report will lack a narrative');

  if (errors.length) return fail(errors, warnings);
  return {
    ok: true,
    errors: [],
    warnings,
    normalized: {
      operating_profile: {
        decision_style: payload.operating_profile.decision_style || null,
        delegation: payload.operating_profile.delegation || null,
        review_thresholds: payload.operating_profile.review_thresholds || null,
        tone: payload.operating_profile.tone || null,
        escalation: payload.operating_profile.escalation || null,
      },
      roles: roles.map((r) => ({
        slug: r.slug,
        name: r.name,
        scope: r.scope || null,
        counterparties: Array.isArray(r.counterparties) ? r.counterparties : [],
        data_classes: Array.isArray(r.data_classes) ? r.data_classes : [],
      })),
      role_boundaries: boundaries.map((b) => ({
        role_a_slug: b.role_a_slug,
        role_b_slug: b.role_b_slug || null,
        boundary_kind: b.boundary_kind,
        description: isNonEmptyString(b.description) ? b.description : '',
        severity: b.severity || 'high',
      })),
      frictions: (Array.isArray(payload.frictions) ? payload.frictions : []).map((f) => ({
        grouping: f.grouping || 'general',
        description: isNonEmptyString(f.description) ? f.description : '',
        impact: f.impact || null,
        frequency: f.frequency || null,
        current_workaround: f.current_workaround || null,
        suggested_agent_function: f.suggested_agent_function || null,
        risk_level: f.risk_level || null,
        priority: Number.isFinite(f.priority) ? f.priority : 0,
      })),
      confidence_notes: confNotes
        .filter(isPlainObject)
        .map((n) => ({ item: isNonEmptyString(n.item) ? n.item : '', confidence: n.confidence || 'needs_confirmation' })),
      summary: isNonEmptyString(payload.summary) ? payload.summary : '',
    },
  };
}

// ── Governance rule ──────────────────────────────────────────────────────────

// A single row of the governance matrix: what an agent may do with an action of
// a given risk on a given data class, who must review it, and whether approval
// is required. Compatible with the runtime trust-mode / approval model.
export function validateGovernanceRule(rule, opts = {}) {
  const errors = [];
  const warnings = [];
  if (!isPlainObject(rule)) return fail(['rule: must be an object']);

  if (!isNonEmptyString(rule.action_type)) pushErr(errors, 'action_type', 'required (string)');
  if (!rule.risk || !GOVERNANCE_RISK_SET.has(rule.risk)) {
    pushErr(errors, 'risk', `must be one of ${[...GOVERNANCE_RISK_SET].join(', ')}`);
  }
  if (!rule.authority || !AGENT_AUTHORITY_SET.has(rule.authority)) {
    pushErr(errors, 'authority', `must be one of ${[...AGENT_AUTHORITY_SET].join(', ')}`);
  }
  if (rule.data_class != null && !isValidSensitivity(rule.data_class)) {
    pushErr(errors, 'data_class', `unknown sensitivity "${rule.data_class}"`);
  }
  if (rule.role_slug != null && !isNonEmptyString(rule.role_slug)) {
    pushErr(errors, 'role_slug', 'must be a non-empty string when present');
  }
  if (rule.reviewer_role != null && !REVIEWER_ROLE_SET.has(rule.reviewer_role)) {
    pushErr(errors, 'reviewer_role', `unknown reviewer role "${rule.reviewer_role}"`);
  }

  const approvalRequired = rule.approval_required === true;
  // Load-bearing safety invariant: anything irreversible_external MUST require
  // approval and name a reviewer — it can never be auto-executed.
  if (rule.risk === 'irreversible_external') {
    if (!approvalRequired) pushErr(errors, 'approval_required', 'must be true for irreversible_external risk');
    if (!rule.reviewer_role) pushErr(errors, 'reviewer_role', 'required for irreversible_external risk');
    if (rule.authority === 'decide') pushErr(errors, 'authority', 'cannot be "decide" for irreversible_external risk');
  }
  if (rule.risk === 'blocked' && rule.authority !== 'escalate' && approvalRequired === false) {
    warnings.push('rule: blocked actions should escalate to a human, not silently no-op');
  }
  if (rule.authority === 'execute' && rule.risk !== 'low' && !approvalRequired) {
    pushErr(errors, 'approval_required', 'execute authority above low risk requires approval');
  }

  if (errors.length) return fail(errors, warnings);
  return {
    ok: true,
    errors: [],
    warnings,
    normalized: {
      action_type: rule.action_type,
      risk: rule.risk,
      authority: rule.authority,
      data_class: rule.data_class || null,
      role_slug: rule.role_slug || null,
      reviewer_role: rule.reviewer_role || null,
      approval_required: approvalRequired,
      evidence_required: rule.evidence_required === true,
      notes: isNonEmptyString(rule.notes) ? rule.notes : null,
    },
  };
}

// ── Agent blueprint ──────────────────────────────────────────────────────────

// A proposed agent. Fields map onto lib/runtime.js::createAgent so the setup
// applier can promote an approved blueprint with no shape translation.
export function validateAgentBlueprint(bp, opts = {}) {
  const errors = [];
  const warnings = [];
  if (!isPlainObject(bp)) return fail(['blueprint: must be an object']);

  if (!bp.id || !SLUG_REGEX.test(bp.id)) {
    pushErr(errors, 'id', `must match ${SLUG_REGEX} (lowercase slug, 3-64 chars)`);
  }
  if (!isNonEmptyString(bp.name)) pushErr(errors, 'name', 'required (string)');
  if (!isNonEmptyString(bp.purpose)) pushErr(errors, 'purpose', 'required (string)');
  if (!isNonEmptyString(bp.role_scope)) pushErr(errors, 'role_scope', 'required (string — the role/capacity this agent serves)');

  const VALID_MEMORY_SCOPES = new Set(['agent', 'project', 'global']);
  if (bp.memory_scope != null && !VALID_MEMORY_SCOPES.has(bp.memory_scope)) {
    pushErr(errors, 'memory_scope', `must be one of ${[...VALID_MEMORY_SCOPES].join(', ')}`);
  }
  if (bp.tools != null && !Array.isArray(bp.tools)) {
    pushErr(errors, 'tools', 'must be an array of tool names');
  }
  if (!bp.risk_tier || !GOVERNANCE_RISK_SET.has(bp.risk_tier)) {
    pushErr(errors, 'risk_tier', `must be one of ${[...GOVERNANCE_RISK_SET].join(', ')}`);
  }
  // An agent that can take any external action must declare review gates.
  if ((bp.risk_tier === 'high' || bp.risk_tier === 'irreversible_external')) {
    if (!Array.isArray(bp.review_gates) || bp.review_gates.length === 0) {
      pushErr(errors, 'review_gates', 'required (non-empty) for high / irreversible_external agents');
    }
  }
  if (bp.engine != null && !isNonEmptyString(bp.engine)) {
    pushErr(errors, 'engine', 'must be a non-empty string when present');
  }
  if (bp.engine === 'remote-http') {
    // Mirrors lib/runtime.js::createAgent — remote-http rows are only written by
    // the module registry, never by HAIL setup.
    pushErr(errors, 'engine', 'remote-http agents are provisioned by the module registry, not HAIL');
  }
  if (bp.approval != null && !['human-in-loop', 'auto'].includes(bp.approval)) {
    pushErr(errors, 'approval', 'must be "human-in-loop" or "auto"');
  }
  if (bp.approval === 'auto' && (bp.risk_tier === 'high' || bp.risk_tier === 'irreversible_external')) {
    warnings.push('blueprint: auto approval on a high/irreversible agent — confirm with security reviewer');
  }

  if (errors.length) return fail(errors, warnings);
  return {
    ok: true,
    errors: [],
    warnings,
    normalized: {
      id: bp.id,
      name: bp.name,
      purpose: bp.purpose,
      role_scope: bp.role_scope,
      memory_scope: bp.memory_scope || 'agent',
      tools: Array.isArray(bp.tools) ? [...bp.tools] : [],
      engine: bp.engine || 'claude-code',
      schedule: isNonEmptyString(bp.schedule) ? bp.schedule : 'on demand',
      risk_tier: bp.risk_tier,
      approval: bp.approval || 'human-in-loop',
      expected_output: isNonEmptyString(bp.expected_output) ? bp.expected_output : null,
      review_gates: Array.isArray(bp.review_gates) ? [...bp.review_gates] : [],
    },
  };
}

// ── Setup package ────────────────────────────────────────────────────────────

// The dry-run bundle a HAIL blueprint produces. The single shape gate between a
// HAIL blueprint and the runtime apply path. Strict by construction.
export function validateSetupPackage(pkg, opts = {}) {
  const errors = [];
  const warnings = [];

  let serialized;
  try {
    serialized = JSON.stringify(pkg);
  } catch {
    return fail(['setup_package: not JSON-serialisable']);
  }
  if (!serialized || serialized === 'undefined') return fail(['setup_package: required']);
  if (Buffer.byteLength(serialized, 'utf8') > MAX_SETUP_PACKAGE_BYTES) {
    return fail([`setup_package: ${Buffer.byteLength(serialized, 'utf8')} bytes exceeds ${MAX_SETUP_PACKAGE_BYTES}-byte cap`]);
  }
  if (!isPlainObject(pkg)) return fail(['setup_package: must be an object']);

  if (!isNonEmptyString(pkg.engagement_id)) pushErr(errors, 'engagement_id', 'required (string)');
  // The package is ALWAYS dry-run when authored. The applier flips this only
  // after the setup_package_approval gate passes — never the author.
  if (pkg.dry_run !== true) pushErr(errors, 'dry_run', 'must be true (packages are authored dry-run only)');

  if (!Array.isArray(pkg.items) || pkg.items.length === 0) {
    pushErr(errors, 'items', 'required (non-empty array)');
    return fail(errors, warnings);
  }
  if (pkg.items.length > MAX_SETUP_ITEMS) {
    pushErr(errors, 'items', `exceeds ${MAX_SETUP_ITEMS}-item cap (got ${pkg.items.length})`);
  }

  const seenRefs = new Set();
  const normalizedItems = [];
  pkg.items.forEach((item, i) => {
    const p = `items[${i}]`;
    if (!isPlainObject(item)) { pushErr(errors, p, 'must be an object'); return; }
    if (!item.type || !SETUP_ITEM_TYPE_SET.has(item.type)) {
      pushErr(errors, `${p}.type`, `unknown type "${item.type}" (allowed: ${[...SETUP_ITEM_TYPE_SET].join(', ')})`);
    }
    if (!isNonEmptyString(item.ref)) {
      pushErr(errors, `${p}.ref`, 'required (string — stable identifier for idempotent apply)');
    } else {
      if (seenRefs.has(item.ref)) pushErr(errors, `${p}.ref`, `duplicate ref "${item.ref}"`);
      seenRefs.add(item.ref);
    }
    if (!item.risk || !GOVERNANCE_RISK_SET.has(item.risk)) {
      pushErr(errors, `${p}.risk`, `must be one of ${[...GOVERNANCE_RISK_SET].join(', ')}`);
    }
    if (!isPlainObject(item.spec)) {
      pushErr(errors, `${p}.spec`, 'required (object — the provisioning payload)');
    }
    // Secrets never carry a value in the package — only a request to collect one.
    if (item.type === 'secret' && isPlainObject(item.spec) && 'value' in item.spec) {
      pushErr(errors, `${p}.spec.value`, 'a setup package must never carry a secret value');
    }
    // Embedded agent blueprints are validated with the dedicated validator.
    if (item.type === 'agent' && isPlainObject(item.spec)) {
      const sub = validateAgentBlueprint(item.spec, opts);
      if (!sub.ok) sub.errors.forEach((e) => pushErr(errors, `${p}.spec`, e));
      sub.warnings.forEach((w) => warnings.push(`${p}: ${w}`));
    }
    normalizedItems.push({
      type: item.type,
      ref: item.ref,
      risk: item.risk,
      requires_approval: item.requires_approval !== false, // default-true
      spec: isPlainObject(item.spec) ? structuredClone(item.spec) : {},
      rationale: isNonEmptyString(item.rationale) ? item.rationale : null,
    });
  });

  if (errors.length) return fail(errors, warnings);
  return {
    ok: true,
    errors: [],
    warnings,
    normalized: {
      engagement_id: pkg.engagement_id,
      dry_run: true,
      generated_by: isNonEmptyString(pkg.generated_by) ? pkg.generated_by : null,
      items: normalizedItems,
    },
  };
}
