#!/usr/bin/env node
/**
 * validate-content.js
 * Sysadmin Chronicles content validation tool.
 *
 * Run from project root:
 *   node tools/content/validate-content.js
 *   node tools/content/validate-content.js --quests-only
 *   node tools/content/validate-content.js --verbose
 *
 * Exit code 0 = no errors. Exit code 1 = validation errors found.
 *
 * Checks performed:
 *   - All JSON files parse correctly
 *   - No duplicate IDs across content domains
 *   - Every world flag referenced anywhere exists in world_flags.json
 *   - Every required_vms entry maps to a known VM profile
 *   - Every blast_radius entry maps to a known incident ID
 *   - Every ticket_id in a quest maps to an existing ticket
 *   - Every linked_quest in a ticket maps to an existing quest
 *   - Branch priorities within a quest are unique (no ties)
 *   - Every follow_up_incident maps to an existing incident file
 *   - Every series_id has at least 2 members
 *   - clue_fingerprint evidence uses valid rule types
 *   - file_absent and file_owner_is_not are accepted rule types (OI-010 resolved)
 *   - Package-manager-specific paths/commands match the authored VM distro
 */

const fs = require("fs");
const path = require("path");

// ---------------------------------------------------------------------------
// CONFIG
// ---------------------------------------------------------------------------

const CONTENT_ROOT = path.resolve(__dirname, "../../content");
const QUESTS_DIR   = path.join(CONTENT_ROOT, "quests");
const TICKETS_DIR  = path.join(CONTENT_ROOT, "tickets");
const INCIDENTS_DIR= path.join(CONTENT_ROOT, "incidents");
const DIALOGUE_DIR = path.join(CONTENT_ROOT, "dialogue");
const FLAGS_FILE   = path.join(CONTENT_ROOT, "world_flags", "world_flags.json");
const VM_PROFILES_DIR = path.join(CONTENT_ROOT, "vm_profiles");
const PRESSURE_PROFILES_DIR = path.join(CONTENT_ROOT, "pressure_profiles");

const VALID_RULE_TYPES = new Set([
  // Standard validation rule types (used in objectives and solution branches)
  "file_exists", "file_absent", "file_contains", "file_mode", "file_owner",
  "file_owner_is_not", "directory_exists", "service_state", "service_enabled",
  "process_running", "process_user", "port_listening", "package_installed",
  "mount_present", "disk_usage_below", "disk_usage_above", "command_assert",
  "log_contains", "and", "or", "not",
  // Advisory clue_fingerprint-only types (descriptive evidence, not used in branch validation)
  // These document what evidence EXISTS in the VM baseline — not evaluated at runtime.
  "service_state_is", "service_enabled_is", "file_size_above", "file_size_below",
]);

const VALID_NARRATIVE_PHASES = new Set([
  "normal_work", "unease", "suspicion", "investigation", "conflict", "resolution"
]);

const args = process.argv.slice(2);
const verbose = args.includes("--verbose");
const questsOnly = args.includes("--quests-only");

// ---------------------------------------------------------------------------
// LOAD HELPERS
// ---------------------------------------------------------------------------

let errors = 0;
let warnings = 0;

function err(msg) {
  console.error(`  ❌ ERROR: ${msg}`);
  errors++;
}

function warn(msg) {
  console.warn(`  ⚠  WARN: ${msg}`);
  warnings++;
}

function ok(msg) {
  if (verbose) console.log(`  ✓ ${msg}`);
}

function loadJson(filePath) {
  try {
    const text = fs.readFileSync(filePath, "utf8");
    return JSON.parse(text);
  } catch (e) {
    err(`JSON parse error in ${path.relative(CONTENT_ROOT, filePath)}: ${e.message}`);
    return null;
  }
}

function loadDir(dirPath, idField = "id") {
  const result = {};
  if (!fs.existsSync(dirPath)) {
    warn(`Directory not found: ${dirPath}`);
    return result;
  }
  for (const fname of fs.readdirSync(dirPath)) {
    if (!fname.endsWith(".json") || fname.startsWith(".")) continue;
    // Skip split artifacts. Pending splits still deserve a warning; completed
    // split archives and backups are intentional and should stay quiet.
    if (fname.includes("SPLIT_PENDING")) {
      warn(`Skipping non-standard file: ${fname}`);
      continue;
    }
    if (fname.includes("SPLIT_DONE") || fname.includes(".bak")) {
      continue;
    }
    const data = loadJson(path.join(dirPath, fname));
    if (!data) continue;
    if (Array.isArray(data)) {
      warn(`${fname} is an array — expected single object with '${idField}' field. Split into individual files.`);
      continue;
    }
    const id = data[idField];
    if (!id) {
      err(`Missing '${idField}' field in ${fname}`);
      continue;
    }
    if (result[id]) {
      err(`Duplicate ID '${id}' in ${fname} (already seen)`);
    }
    result[id] = { data, fname };
  }
  return result;
}

// ---------------------------------------------------------------------------
// RULE TYPE VALIDATOR (recursive)
// ---------------------------------------------------------------------------

function validateRuleTypes(rule, context) {
  if (!rule || typeof rule !== "object") return;
  const type = rule.type;
  if (!type) {
    err(`${context}: rule missing 'type' field`);
    return;
  }
  if (!VALID_RULE_TYPES.has(type)) {
    err(`${context}: unknown rule type '${type}'`);
    return;
  }
  // Recurse into composite rules
  if (type === "and" || type === "or") {
    for (const sub of (rule.rules || [])) {
      validateRuleTypes(sub, context);
    }
  }
  if (type === "not") {
    validateRuleTypes(rule.rule, context);
  }
}

// ---------------------------------------------------------------------------
// COLLECT ALL FLAG REFERENCES IN A RULE (recursive)
// ---------------------------------------------------------------------------

function collectFlagRefs(obj, refs = new Set()) {
  if (!obj || typeof obj !== "object") return refs;
  if (Array.isArray(obj)) {
    for (const item of obj) collectFlagRefs(item, refs);
    return refs;
  }
  for (const [k, v] of Object.entries(obj)) {
    if (k === "world_flags" && Array.isArray(v)) {
      for (const f of v) refs.add(f);
    } else if (k === "trigger" && typeof v === "string" && v.startsWith("world_flag:")) {
      refs.add(v.slice("world_flag:".length));
    } else if (typeof v === "object") {
      collectFlagRefs(v, refs);
    }
  }
  return refs;
}

function distroForVm(vmId, vmProfiles) {
  return vmProfiles[vmId]?.data?.distro || "";
}

function validateVmSpecificSemantics(rule, context, vmProfiles) {
  if (!rule || typeof rule !== "object") return;
  if (Array.isArray(rule)) {
    for (const item of rule) validateVmSpecificSemantics(item, context, vmProfiles);
    return;
  }

  const vmId = typeof rule.vm === "string" ? rule.vm : "";
  const distro = distroForVm(vmId, vmProfiles);
  const pathValue = typeof rule.path === "string" ? rule.path : "";
  const commandValue = typeof rule.command === "string" ? rule.command : "";

  if (vmId && distro) {
    const pathCtx = `${context} (${vmId}/${distro})`;
    if (pathValue === "/etc/pacman.conf" && distro !== "arch") {
      err(`${pathCtx}: /etc/pacman.conf is Arch-specific but VM distro is '${distro}'`);
    }
    if (pathValue.startsWith("/etc/apt/") && distro === "arch") {
      err(`${pathCtx}: /etc/apt/* is Debian/Ubuntu-specific but VM distro is 'arch'`);
    }
    if (commandValue.includes("pacman") && distro !== "arch") {
      err(`${pathCtx}: command references pacman but VM distro is '${distro}'`);
    }
    if ((commandValue.includes("apt ") || commandValue.includes("apt-get") || commandValue.includes("dpkg ")) && distro === "arch") {
      err(`${pathCtx}: command references apt/dpkg but VM distro is 'arch'`);
    }
  }

  for (const value of Object.values(rule)) {
    if (value && typeof value === "object") {
      validateVmSpecificSemantics(value, context, vmProfiles);
    }
  }
}

// ---------------------------------------------------------------------------
// MAIN VALIDATION
// ---------------------------------------------------------------------------

console.log("═══════════════════════════════════════");
console.log("  Sysadmin Chronicles Content Validator");
console.log("═══════════════════════════════════════\n");

// Load all content
console.log("Loading content...");
const quests    = loadDir(QUESTS_DIR);
const tickets   = loadDir(TICKETS_DIR);
const incidents = loadDir(INCIDENTS_DIR);
const dialogue  = loadDir(DIALOGUE_DIR);
const vmProfiles= loadDir(VM_PROFILES_DIR);
const pressureProfiles = loadDir(PRESSURE_PROFILES_DIR);
const flagsRaw  = loadJson(FLAGS_FILE);
// flags field may be an Array (list of {id, ...}) or a Dict (id -> {...})
// Normalize to Dict keyed by id for validation lookups.
let flagRegistry = {};
if (flagsRaw) {
  const raw = flagsRaw.flags || flagsRaw;
  if (Array.isArray(raw)) {
    for (const f of raw) { if (f.id) flagRegistry[f.id] = f; }
  } else if (typeof raw === "object") {
    flagRegistry = raw;
  }
}

console.log(`  Quests: ${Object.keys(quests).length}, Tickets: ${Object.keys(tickets).length}, Incidents: ${Object.keys(incidents).length}, Dialogue: ${Object.keys(dialogue).length}, Pressure Profiles: ${Object.keys(pressureProfiles).length}\n`);

// ---------------------------------------------------------------------------
// QUEST VALIDATION
// ---------------------------------------------------------------------------

console.log("Validating quests...");
for (const [qid, { data: quest, fname }] of Object.entries(quests)) {
  const ctx = `Quest ${qid} (${fname})`;

  // required_vms
  for (const vmId of (quest.required_vms || [])) {
    if (!vmProfiles[vmId]) err(`${ctx}: required_vms references unknown VM profile '${vmId}'`);
  }

  // baseline_snapshot
  if (!quest.baseline_snapshot || typeof quest.baseline_snapshot !== 'string' || !quest.baseline_snapshot.trim()) {
    err(`${ctx}: missing or empty 'baseline_snapshot' field`);
  }

  // ticket_id
  if (quest.ticket_id && !tickets[quest.ticket_id]) {
    err(`${ctx}: ticket_id '${quest.ticket_id}' not found in tickets/`);
  }

  // pressure_profile
  if (quest.pressure_profile && !pressureProfiles[quest.pressure_profile]) {
    err(`${ctx}: pressure_profile '${quest.pressure_profile}' not found in pressure_profiles/`);
  }

  // blast_radius
  for (const incId of (quest.blast_radius || [])) {
    if (!incidents[incId]) warn(`${ctx}: blast_radius references '${incId}' which doesn't exist yet`);
  }

  // Branch priority uniqueness
  const branches = quest.solution_branches || [];
  const priorities = new Set();
  for (const branch of branches) {
    const p = branch.priority;
    if (priorities.has(p)) {
      err(`${ctx}: duplicate branch priority ${p} — each branch must have a unique priority`);
    }
    priorities.add(p);

    // follow_up_incident
    if (branch.follow_up_incident && !incidents[branch.follow_up_incident]) {
      err(`${ctx}: branch '${branch.id}' follow_up_incident '${branch.follow_up_incident}' not found`);
    }

    // Validate branch validation rules
    validateRuleTypes(branch.validation, `${ctx} branch '${branch.id}'`);
  }

  // clue_fingerprint evidence rule types
  const evidence = (quest.clue_fingerprint || {}).evidence || [];
  for (const ev of evidence) {
    if (!VALID_RULE_TYPES.has(ev.type)) {
      err(`${ctx}: clue_fingerprint evidence uses unknown rule type '${ev.type}'`);
    }
    validateVmSpecificSemantics(ev, `${ctx} clue_fingerprint`, vmProfiles);
  }

  // Objective rule types
  for (const obj of (quest.objectives || [])) {
    validateRuleTypes(obj.validation, `${ctx} objective '${obj.id}'`);
    validateVmSpecificSemantics(obj.validation, `${ctx} objective '${obj.id}'`, vmProfiles);
  }

  for (const branch of branches) {
    validateVmSpecificSemantics(branch.validation, `${ctx} branch '${branch.id}'`, vmProfiles);
  }

  // narrative_phase
  if (!quest.narrative_phase) {
    warn(`${ctx}: missing 'narrative_phase' field`);
  } else if (!VALID_NARRATIVE_PHASES.has(quest.narrative_phase)) {
    err(`${ctx}: unknown narrative_phase '${quest.narrative_phase}'`);
  }

  // behavior_impact
  if (quest.behavior_impact !== undefined) {
    for (const [branchKey, impact] of Object.entries(quest.behavior_impact)) {
      for (const field of ['curiosity_delta', 'obedience_delta', 'risk_delta', 'suspicion_delta']) {
        if (impact[field] !== undefined && typeof impact[field] !== 'number') {
          err(`${ctx}: behavior_impact[${branchKey}].${field} must be a number`);
        }
      }
    }
  }

  // hidden_hook shape
  if (quest.hidden_hook !== undefined && quest.hidden_hook !== null) {
    if (typeof quest.hidden_hook.id !== 'string') {
      err(`${ctx}: hidden_hook.id must be a string`);
    }
  }

  // access_requirements
  if (quest.access_requirements?.minimum_access) {
    for (const [vmId] of Object.entries(quest.access_requirements.minimum_access)) {
      if (!vmProfiles[vmId]) {
        err(`${ctx}: access_requirements.minimum_access references unknown VM '${vmId}'`);
      }
    }
  }

  ok(`${ctx}: OK`);
}

if (!questsOnly) {
  // ---------------------------------------------------------------------------
  // TICKET VALIDATION
  // ---------------------------------------------------------------------------

  console.log("\nValidating tickets...");
  for (const [tid, { data: ticket, fname }] of Object.entries(tickets)) {
    const ctx = `Ticket ${tid} (${fname})`;

    if (ticket.linked_quest && !quests[ticket.linked_quest]) {
      warn(`${ctx}: linked_quest '${ticket.linked_quest}' not found`);
    }

    if (ticket.initial_priority === undefined) {
      err(`${ctx}: missing 'initial_priority'`);
    }
    if (ticket.current_priority === undefined) {
      err(`${ctx}: missing 'current_priority'`);
    }
    if (ticket.initial_priority !== ticket.current_priority) {
      warn(`${ctx}: initial_priority != current_priority at authoring time`);
    }
    ok(`${ctx}: OK`);
  }

  // ---------------------------------------------------------------------------
  // INCIDENT VALIDATION
  // ---------------------------------------------------------------------------

  console.log("\nValidating incidents...");
  for (const [iid, { data: incident, fname }] of Object.entries(incidents)) {
    const ctx = `Incident ${iid} (${fname})`;

    if (!Array.isArray(incident.blast_radius_quests)) {
      err(`${ctx}: missing 'blast_radius_quests' (must be present, can be [])`);
    }
    if (!Array.isArray(incident.blast_radius_incidents)) {
      err(`${ctx}: missing 'blast_radius_incidents' (must be present, can be [])`);
    }
    if (incident.resolution_requirements?.validation) {
      validateRuleTypes(incident.resolution_requirements.validation, `${ctx} resolution_requirements`);
      validateVmSpecificSemantics(incident.resolution_requirements.validation, `${ctx} resolution_requirements`, vmProfiles);
    }
    ok(`${ctx}: OK`);
  }

  // ---------------------------------------------------------------------------
  // DIALOGUE VALIDATION
  // ---------------------------------------------------------------------------

  console.log("\nValidating dialogue...");
  const seriesMembers = {};
  for (const [did, { data: dlg, fname }] of Object.entries(dialogue)) {
    const ctx = `Dialogue ${did} (${fname})`;

    if (dlg.series_id) {
      if (dlg.series_position === undefined) {
        err(`${ctx}: has series_id '${dlg.series_id}' but missing series_position`);
      }
      if (!seriesMembers[dlg.series_id]) seriesMembers[dlg.series_id] = [];
      seriesMembers[dlg.series_id].push(did);
    }

    // world_flag trigger
    if (dlg.trigger && dlg.trigger.startsWith("world_flag:")) {
      const flagId = dlg.trigger.slice("world_flag:".length);
      if (!flagRegistry[flagId]) {
        err(`${ctx}: trigger flag '${flagId}' not in world_flags registry`);
      }
    }
    ok(`${ctx}: OK`);
  }

  // series membership count check
  for (const [sid, members] of Object.entries(seriesMembers)) {
    if (members.length < 2) {
      warn(`Series '${sid}' has only 1 member (${members[0]}) — needs 2+ for series tracking`);
    }
  }

  // ---------------------------------------------------------------------------
  // WORLD FLAG CROSS-REFERENCE
  // ---------------------------------------------------------------------------

  console.log("\nValidating world flag references...");
  const allContent = [
    ...Object.values(quests).map(v => v.data),
    ...Object.values(tickets).map(v => v.data),
    ...Object.values(incidents).map(v => v.data),
    ...Object.values(dialogue).map(v => v.data),
  ];

  const referencedFlags = new Set();
  for (const item of allContent) {
    collectFlagRefs(item, referencedFlags);
  }

  for (const flagId of referencedFlags) {
    if (!flagRegistry[flagId]) {
      err(`World flag '${flagId}' is referenced in content but not declared in world_flags.json`);
    }
  }
}

// ---------------------------------------------------------------------------
// SUMMARY
// ---------------------------------------------------------------------------

console.log("\n═══════════════════════════════════════");
if (errors === 0 && warnings === 0) {
  console.log("  ✅ All content valid. Zero errors, zero warnings.");
} else {
  if (errors > 0) console.log(`  ❌ ${errors} error(s) found.`);
  if (warnings > 0) console.log(`  ⚠  ${warnings} warning(s) found.`);
}
console.log("═══════════════════════════════════════\n");
process.exit(errors > 0 ? 1 : 0);
