import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { cards as authoredCards, longlist as authoredLonglist, assertContent } from './content.mjs';
import { assertCompletedCardRelationships, assertSystems, simulateDivination, simulateGames, simulateGenerators, validateGameSpecs } from './systems.mjs';
import { classes, referenceNames } from './catalog.mjs';
import { CRITERIA as candidateCriteria } from './candidate-registry.mjs';
import { sources } from './source-registry.mjs';

const root=join(dirname(fileURLToPath(import.meta.url)),'..');
const failures=[];const passed=[];
const check=(condition,message,detail='')=>{(condition?passed:failures).push({message,detail});};
const exists=async path=>access(join(root,path)).then(()=>true).catch(()=>false);
const load=async path=>JSON.parse(await readFile(join(root,path),'utf8'));

function schemaTypeMatches(value,type){
  if(Array.isArray(type))return type.some(candidate=>schemaTypeMatches(value,candidate));
  if(type==='null')return value===null;
  if(type==='array')return Array.isArray(value);
  if(type==='object')return value!==null&&typeof value==='object'&&!Array.isArray(value);
  if(type==='integer')return Number.isInteger(value);
  if(type==='number')return typeof value==='number'&&Number.isFinite(value);
  return typeof value===type;
}

function validateSchema(value,schema,path,registry,errors,seenRefs=new Set(),rootSchema=schema){
  if(!schema||typeof schema!=='object')return;
  if(schema.$ref){
    const isLocal=schema.$ref.startsWith('#/');
    const key=isLocal?`local:${schema.$ref}`:schema.$ref.replace(/^\.\//,'');
    const referenceVisit=`${key}@${path}`;
    if(seenRefs.has(referenceVisit)){errors.push(`${path}: circular schema reference ${key}`);return;}
    const target=isLocal
      ? schema.$ref.slice(2).split('/').reduce((current,part)=>current?.[part.replace(/~1/g,'/').replace(/~0/g,'~')],rootSchema)
      : registry[key];
    if(!target){errors.push(`${path}: unresolved schema reference ${schema.$ref}`);return;}
    validateSchema(value,target,path,registry,errors,new Set([...seenRefs,referenceVisit]),isLocal?rootSchema:target);
  }
  for(const part of schema.allOf??[])validateSchema(value,part,path,registry,errors,seenRefs,rootSchema);
  if(Object.hasOwn(schema,'const')&&JSON.stringify(value)!==JSON.stringify(schema.const))errors.push(`${path}: expected constant ${JSON.stringify(schema.const)}`);
  if(schema.enum&&!schema.enum.some(candidate=>JSON.stringify(candidate)===JSON.stringify(value)))errors.push(`${path}: value is outside enum`);
  if(schema.type&&!schemaTypeMatches(value,schema.type)){errors.push(`${path}: expected ${schema.type}`);return;}
  if(typeof value==='string'){
    if(Number.isFinite(schema.minLength)&&value.length<schema.minLength)errors.push(`${path}: shorter than minLength ${schema.minLength}`);
    if(schema.pattern&&!new RegExp(schema.pattern).test(value))errors.push(`${path}: does not match ${schema.pattern}`);
  }
  if(typeof value==='number'){
    if(Number.isFinite(schema.minimum)&&value<schema.minimum)errors.push(`${path}: below minimum ${schema.minimum}`);
    if(Number.isFinite(schema.maximum)&&value>schema.maximum)errors.push(`${path}: above maximum ${schema.maximum}`);
  }
  if(Array.isArray(value)){
    if(Number.isFinite(schema.minItems)&&value.length<schema.minItems)errors.push(`${path}: fewer than ${schema.minItems} items`);
    if(schema.uniqueItems&&new Set(value.map(item=>JSON.stringify(item))).size!==value.length)errors.push(`${path}: items are not unique`);
    if(schema.items)value.forEach((item,index)=>validateSchema(item,schema.items,`${path}[${index}]`,registry,errors,seenRefs,rootSchema));
  }
  if(value!==null&&typeof value==='object'&&!Array.isArray(value)){
    for(const key of schema.required??[])if(!Object.hasOwn(value,key))errors.push(`${path}: missing required property ${key}`);
    for(const [key,child] of Object.entries(schema.properties??{}))if(Object.hasOwn(value,key))validateSchema(value[key],child,`${path}.${key}`,registry,errors,seenRefs,rootSchema);
    if(schema.additionalProperties===false){
      const allowed=new Set(Object.keys(schema.properties??{}));
      for(const key of Object.keys(value))if(!allowed.has(key))errors.push(`${path}: unexpected property ${key}`);
    }
  }
}

async function walk(path){
  const files=[];
  for(const entry of await readdir(path,{withFileTypes:true})){const full=join(path,entry.name);if(entry.isDirectory())files.push(...await walk(full));else files.push(full);}return files;
}

function requiredCardComplete(card){
  const scalar=['id','title','class','className','culturalHistory','essence','archetype','gift','shadow','guidance','question'];
  return scalar.every(k=>typeof card[k]==='string'&&card[k].trim().length>2)
    && Array.isArray(card.domains)&&card.domains.length>=1
    && Array.isArray(card.materialModes)&&card.materialModes.length>=1
    && Array.isArray(card.epistemicLayers)&&card.epistemicLayers.length>=3
    && Array.isArray(card.sources)&&card.sources.length>=2
    && ['psychological','social','spiritual','technological','political'].every(k=>card.meanings?.[k]?.length>80)
    && card.integration?.practice?.length>40&&card.visual?.centralMotif?.length>20;
}

function pngDimensions(buffer){return buffer.length>25&&buffer.toString('ascii',1,4)==='PNG'?{width:buffer.readUInt32BE(16),height:buffer.readUInt32BE(20),colorType:buffer[25]}:null;}

function markdownMetrics(text){
  const withoutFrontmatter=text.replace(/^---\s*[\s\S]*?\n---\s*/,'');
  const withoutCode=withoutFrontmatter.replace(/```[\s\S]*?```/g,' ');
  const words=(withoutCode.match(/[\p{L}\p{N}][\p{L}\p{N}\p{M}’'\-]*/gu)??[]).length;
  const headings=[...withoutCode.matchAll(/^#{1,6}\s+(.+)$/gm)].map(match=>match[1].trim());
  const levelTwoHeadings=[...withoutCode.matchAll(/^##\s+(.+)$/gm)].map(match=>match[1].trim());
  const proseOnly=withoutCode.split('\n').filter(line=>{
    const trimmed=line.trim();
    return trimmed&&!/^#{1,6}\s|^[-*+]\s|^\d+[.)]\s|^>|^\|/.test(trimmed);
  }).join(' ');
  const proseWords=(proseOnly.match(/[\p{L}\p{N}][\p{L}\p{N}\p{M}’'\-]*/gu)??[]).length;
  return {words,headings:headings.length,levelTwoHeadings:levelTwoHeadings.length,proseWords};
}

async function documentDepthFailures(directory,names,contract){
  const issues=[];const metrics={};
  for(const name of names){
    const relativePath=`${directory}/${name}.md`;
    try{
      const text=await readFile(join(root,relativePath),'utf8');
      const measured=markdownMetrics(text);metrics[name]=measured;
      const expected=typeof contract==='function'?contract(name):contract;
      if(measured.words<expected.words)issues.push(`${relativePath}: ${measured.words}/${expected.words} words`);
      if(measured.headings<expected.headings)issues.push(`${relativePath}: ${measured.headings}/${expected.headings} headings`);
      if(measured.levelTwoHeadings<(expected.levelTwoHeadings??0))issues.push(`${relativePath}: ${measured.levelTwoHeadings}/${expected.levelTwoHeadings} level-two sections`);
      if(measured.proseWords<(expected.proseWords??0))issues.push(`${relativePath}: ${measured.proseWords}/${expected.proseWords} prose words outside lists`);
    }catch{issues.push(`${relativePath}: missing`);}
  }
  return {issues,metrics};
}

const escapeRegExp=value=>value.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
function normalizeCardText(card,value){
  let text=String(value??'').normalize('NFKD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[“”]/g,'"').replace(/[‘’]/g,"'");
  const concepts=[card.title,...(card.aliases??[])].filter(Boolean).map(item=>String(item).normalize('NFKD').replace(/[\u0300-\u036f]/g,'').toLowerCase()).sort((a,b)=>b.length-a.length);
  for(const concept of concepts)text=text.replace(new RegExp(`(^|\\b)${escapeRegExp(concept)}(?=\\b|$)`,'g'),'$1 <concept> ');
  for(const label of [card.className,card.class,...(card.domainNames??[]),card.primaryDomain].filter(Boolean).map(String).sort((a,b)=>b.length-a.length)){
    const normalized=label.normalize('NFKD').replace(/[\u0300-\u036f]/g,'').toLowerCase();
    text=text.replace(new RegExp(`(^|\\b)${escapeRegExp(normalized)}(?=\\b|$)`,'g'),'$1 <taxonomy> ');
  }
  return text.replace(/[^a-z0-9<>]+/g,' ').replace(/\s+/g,' ').trim();
}

function shingleSet(text,size=3){
  const tokens=text.split(' ').filter(Boolean);const output=new Set();
  for(let index=0;index<=tokens.length-size;index++)output.add(tokens.slice(index,index+size).join(' '));
  return output;
}

function jaccard(a,b){
  let intersection=0;for(const item of a)if(b.has(item))intersection++;
  return intersection/(a.size+b.size-intersection||1);
}

async function validateMarkdownLinks(files){
  const broken=[];
  for(const file of files.filter(f=>f.endsWith('.md'))){
    const text=await readFile(file,'utf8');
    for(const match of text.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)){
      const raw=match[1].trim().replace(/^<|>$/g,'').split('#')[0];
      if(!raw||/^(https?:|mailto:|local:)/.test(raw))continue;
      const target=resolve(dirname(file),decodeURIComponent(raw));
      try{await access(target);}catch{broken.push(`${relative(root,file)} → ${raw}`);}
    }
  }
  return broken;
}

async function main(){
  assertContent(authoredCards,authoredLonglist);
  const systems=assertSystems(authoredCards);
  const [cards,longlist,relationships,lineages,spreads,games,references,generatedSources]=await Promise.all(['cards','longlist','relationships','lineages','spreads','games','reference-tiles','sources'].map(n=>load(`data/${n}.json`)));
  assertCompletedCardRelationships(cards,systems);

  check(cards.length===480,'Core canon contains exactly 480 Tiles',String(cards.length));
  check(longlist.length>=1500,'Longlist contains at least 1,500 evaluated candidates',String(longlist.length));
  const decisions=Object.fromEntries(['core','expansion-ready','research-reserve','merged','deferred','excluded'].map(d=>[d,longlist.filter(x=>x.decision===d).length]));
  const allowedDecisions=new Set(Object.keys(decisions));
  const sourceIds=new Set(sources.map(source=>source.id));
  const nonCore=longlist.filter(candidate=>candidate.decision!=='core');
  check(decisions.core===480,'Candidate registry contains exactly 480 explicitly approved core records',JSON.stringify(decisions));
  check(nonCore.length>=1020,'Candidate registry contains at least 1,020 independent non-core records',String(nonCore.length));
  check(longlist.every(candidate=>allowedDecisions.has(candidate.decision)),'Candidate dispositions use only the declared editorial vocabulary',longlist.filter(candidate=>!allowedDecisions.has(candidate.decision)).slice(0,12).map(candidate=>`${candidate.id}:${candidate.decision}`).join(', '));
  check(decisions['expansion-ready']>0&&decisions['research-reserve']>0,'Natural dispositions include both expansion-ready and research-reserve candidates',JSON.stringify(decisions));
  check(new Set(cards.map(c=>c.id)).size===cards.length,'Card IDs are unique');
  check(new Set(longlist.map(c=>c.id)).size===longlist.length,'Candidate IDs are unique');
  const normalizedCandidateTitles=longlist.map(candidate=>String(candidate.title??'').normalize('NFKC').toLocaleLowerCase('en'));
  check(new Set(normalizedCandidateTitles).size===longlist.length,'Candidate titles are unique after Unicode normalization and case-folding');
  const candidateCriterionKeys=[...candidateCriteria].sort();
  const invalidCandidateEvaluations=[];
  for(const candidate of longlist){
    const scoreKeys=Object.keys(candidate.scoring?.dimensions??{}).sort();
    const noteKeys=Object.keys(candidate.scoring?.criterionNotes??{}).sort();
    const scores=Object.values(candidate.scoring?.dimensions??{});
    const notes=Object.values(candidate.scoring?.criterionNotes??{});
    const externalAnchors=(candidate.sourceAnchors??[]).filter(sourceId=>sourceId!=='original-synthesis');
    const minimumRationale=candidate.decision==='core'?120:180;
    if(JSON.stringify(scoreKeys)!==JSON.stringify(candidateCriterionKeys)
      ||JSON.stringify(noteKeys)!==JSON.stringify(candidateCriterionKeys)
      ||scores.some(score=>!Number.isFinite(score)||score<0||score>100)
      ||notes.some(note=>typeof note!=='string'||note.trim().length<55)
      ||!Number.isFinite(candidate.scoring?.weightedScore)
      ||typeof candidate.rationale!=='string'||candidate.rationale.trim().length<minimumRationale
      ||typeof (candidate.definition??candidate.essence)!=='string'||(candidate.definition??candidate.essence).trim().length<35
      ||typeof (candidate.originContext??candidate.origin?.summary)!=='string'||(candidate.originContext??candidate.origin?.summary).trim().length<80
      ||!Array.isArray(candidate.sourceAnchors)||!candidate.sourceAnchors.length
      ||!externalAnchors.length
      ||candidate.sourceAnchors.some(sourceId=>!sourceIds.has(sourceId))
      ||(candidate.decision!=='core'&&Object.hasOwn(candidate,'parentCardId'))){
      invalidCandidateEvaluations.push(candidate.id??'(missing-id)');
    }
  }
  check(!invalidCandidateEvaluations.length,'Every candidate has independent, source-anchored prose plus eight explicit scores and eight criterion notes',invalidCandidateEvaluations.slice(0,20).join(', '));
  check(nonCore.every(candidate=>candidate.decision!=='core'&&!Object.hasOwn(candidate,'parentCardId')),'Non-core candidates are independent records rather than facets of core cards');
  check(cards.every(requiredCardComplete),'Every core Tile has complete history, meanings, divination, integration, provenance, game, and visual fields');
  const differentiationFields={
    'origin.summary':card=>card.origin?.summary,'culturalHistory':card=>card.culturalHistory,'essence':card=>card.essence,
    'meanings.psychological':card=>card.meanings?.psychological,'meanings.social':card=>card.meanings?.social,'meanings.spiritual':card=>card.meanings?.spiritual,
    'meanings.technological':card=>card.meanings?.technological,'meanings.political':card=>card.meanings?.political,
    'divination.signal':card=>card.divination?.signal,'guidance':card=>card.guidance,'integration.practice':card=>card.integration?.practice,'question':card=>card.question
  };
  const repeatedTemplates=[];
  for(const [field,getter] of Object.entries(differentiationFields)){
    const groups=new Map();for(const card of cards){const normalized=normalizeCardText(card,getter(card));groups.set(normalized,(groups.get(normalized)??0)+1);}
    const maximum=Math.max(...groups.values());if(maximum>8)repeatedTemplates.push(`${field}:${maximum}`);
  }
  check(!repeatedTemplates.length,'No normalized core-prose template is reused by more than eight Tiles',repeatedTemplates.join(', '));
  const sentenceFrequency=new Map();
  for(const card of cards){for(const getter of Object.values(differentiationFields)){for(const sentence of String(getter(card)??'').split(/(?<=[.!?])\s+/)){const normalized=normalizeCardText(card,sentence);if(normalized.length<40)continue;sentenceFrequency.set(normalized,(sentenceFrequency.get(normalized)??0)+1);}}}
  const overusedSentences=[...sentenceFrequency.entries()].filter(([,count])=>count>Math.floor(cards.length*.10)).sort((a,b)=>b[1]-a[1]);
  check(!overusedSentences.length,'No non-safety semantic sentence is reused on more than ten percent of Tiles',overusedSentences.slice(0,12).map(([text,count])=>`${count}× ${text.slice(0,90)}`).join('; '));
  const combinedNormalized=cards.map(card=>normalizeCardText(card,Object.values(differentiationFields).map(getter=>getter(card)).join(' ')));
  const shingles=combinedNormalized.map(text=>shingleSet(text));const nearDuplicateCards=new Set();
  for(let left=0;left<cards.length;left++)for(let right=left+1;right<cards.length;right++)if(jaccard(shingles[left],shingles[right])>=0.82){nearDuplicateCards.add(cards[left].id);nearDuplicateCards.add(cards[right].id);}
  check(nearDuplicateCards.size<=Math.floor(cards.length*.05),'At least 95% of Tiles avoid near-duplicate combined prose at the 0.82 shingle-Jaccard threshold',`${nearDuplicateCards.size} near-duplicate Tiles`);
  check(cards.every(c=>c.relationships.length>=4),'Every generated card record lists at least four relationship IDs');
  check(cards.every(c=>c.relationshipRequirements?.status==='complete'),'Every generated card records a completed graph attachment');
  check(classes.every(cls=>cards.filter(c=>c.class===cls.id).length===cls.target),'Class counts match the justified 13-class taxonomy');
  check(cards.every(c=>c.sources.every(id=>sourceIds.has(id))),'Every card source ID resolves in the source registry');
  check(generatedSources.length===sources.length&&generatedSources.every(s=>s.title&&s.publisher&&/^https:\/\/|^local:\/\//.test(s.url)),'Source registry is complete and uses resolvable URL schemes');

  const requiredTitles=['QAnon','MAHA Movement','Anti-Vaccine Movement','15-Minute City','Prime Directive','Loosh','Loosh Harvest','Akashic Records','Dark Matter','Great Filter','Dune','Cyberpunk','Monolith','The Matrix','Structured Water','Med Bed'];
  check(requiredTitles.every(title=>cards.some(c=>c.title===title)),'All mandatory sensitive and lineage anchors are present',requiredTitles.filter(t=>!cards.some(c=>c.title===t)).join(', '));

  check(JSON.stringify(relationships)===JSON.stringify(systems.relationships),'Generated relationship data is an exact serialization of the canonical systems graph',`${relationships.length} generated / ${systems.relationships.length} canonical edges`);
  const cardIds=new Set(cards.map(c=>c.id));const edgeTypes=new Set(systems.edgeTypes.map(e=>e.id));const relationshipIds=new Set(relationships.map(e=>e.id));
  check(relationships.every(e=>cardIds.has(e.source)&&cardIds.has(e.target)&&e.source!==e.target&&edgeTypes.has(e.type)),'All relationship endpoints and edge types resolve');
  check(relationships.every(e=>e.type!=='related-to'&&e.type!=='related_to'),'Generic related-to edges are absent');
  check(relationships.every(e=>e.rationale?.length>60&&e.provenance?.kind&&Number.isFinite(e.confidence)),'Every relationship has rationale, basis, provenance, and numeric confidence');
  check(relationships.every(edge=>edge.derivation?.kind&&typeof edge.reviewStatus==='string'&&typeof edge.assertiveness==='string'&&typeof edge.substantive==='boolean'),'Every relationship declares derivation, review status, assertiveness, and substantive status');
  const invalidAssertiveEdges=relationships.filter(edge=>edge.assertiveness==='assertive'
    &&(!['curated','lineage'].includes(edge.derivation?.kind)||edge.reviewStatus!=='reviewed'||edge.substantive!==true));
  check(!invalidAssertiveEdges.length,'Every assertive relationship is reviewed, curated or lineage-derived, and substantive',invalidAssertiveEdges.slice(0,12).map(edge=>edge.id).join(', '));
  const invalidSubstantiveEdges=relationships.filter(edge=>edge.substantive
    &&(edge.assertiveness!=='assertive'||!['curated','lineage'].includes(edge.derivation?.kind)||edge.reviewStatus!=='reviewed'));
  check(!invalidSubstantiveEdges.length,'Only reviewed assertive curated or lineage relationships are substantive',invalidSubstantiveEdges.slice(0,12).map(edge=>edge.id).join(', '));
  const invalidRuleEdges=relationships.filter(edge=>edge.derivation?.kind==='rule'
    &&(edge.substantive!==false||!['literal-classification','nonassertive'].includes(edge.assertiveness)));
  check(!invalidRuleEdges.length,'Rule-derived relationships remain non-substantive literal classification or nonassertive inquiry links',invalidRuleEdges.slice(0,12).map(edge=>edge.id).join(', '));
  const cardsById=new Map(cards.map(card=>[card.id,card]));
  const invalidSourcedEdges=relationships.filter(edge=>{
    if(edge.provenance?.kind!=='sourced')return false;
    const shared=new Set((cardsById.get(edge.source)?.sources??[]).filter(id=>(cardsById.get(edge.target)?.sources??[]).includes(id)));
    const cited=edge.provenance?.sourceIds??[];
    return !cited.length||cited.some(id=>!shared.has(id));
  });
  check(!invalidSourcedEdges.length,'Every sourced relationship cites a real source shared by both endpoint cards',invalidSourcedEdges.slice(0,12).map(edge=>edge.id).join(', '));
  check(!systems.graphDiagnostics.invalidSourcedEdgeCitations.length,'Graph diagnostics report zero invalid sourced-edge citations',systems.graphDiagnostics.invalidSourcedEdgeCitations.slice(0,12).join('; '));
  check(!systems.graphDiagnostics.semanticFailures.length,'Graph semantic audit reports zero failures',systems.graphDiagnostics.semanticFailures.slice(0,12).join('; '));
  check(systems.graphDiagnostics.orphanIds.length===0&&systems.graphDiagnostics.minimumDegree>=4&&systems.graphDiagnostics.belowFourNeighborIds.length===0,'Graph has no orphans and minimum degree is at least four');
  check(systems.graphDiagnostics.minimumEdgeFamilies>=3&&systems.graphDiagnostics.belowThreeFamilyIds.length===0,'Every Tile participates in at least three edge families');
  check(!systems.graphDiagnostics.missingOriginalSynthesisIds.length&&!systems.graphDiagnostics.missingCrossDomainIds.length,'Every Tile has original-synthesis and cross-domain graph coverage',`original-synthesis gaps=${systems.graphDiagnostics.missingOriginalSynthesisIds.length}; cross-domain gaps=${systems.graphDiagnostics.missingCrossDomainIds.length}`);
  const reviewedSubstantiveEdges=relationships.filter(edge=>edge.substantive&&edge.reviewStatus==='reviewed');
  const reviewedSubstantiveCardIds=new Set(reviewedSubstantiveEdges.flatMap(edge=>[edge.source,edge.target]));
  const computedSubstantiveCoverage=cards.length?reviewedSubstantiveCardIds.size/cards.length:0;
  check(reviewedSubstantiveEdges.length>0&&systems.graphDiagnostics.reviewedSubstantiveEdgeCount===reviewedSubstantiveEdges.length,'Graph contains reviewed substantive editorial relationships and tracks their exact count',`${reviewedSubstantiveEdges.length} reviewed substantive edges`);
  check(Number.isFinite(systems.graphDiagnostics.reviewedSubstantiveCoverage)
    &&Math.abs(systems.graphDiagnostics.reviewedSubstantiveCoverage-computedSubstantiveCoverage)<1e-12
    &&systems.graphDiagnostics.missingReviewedSubstantiveIds.length===cards.length-reviewedSubstantiveCardIds.size,
  'Reviewed substantive coverage is explicitly tracked without requiring it on every Tile',`${(computedSubstantiveCoverage*100).toFixed(2)}% coverage; ${systems.graphDiagnostics.missingReviewedSubstantiveIds.length} Tiles intentionally uncovered`);
  check(lineages.length===14&&lineages.every(l=>l.cardIds.length>=4&&l.cardIds.every(id=>cardIds.has(id))&&l.edgeIds.every(id=>relationshipIds.has(id))),'Fourteen lineages reference canonical cards and canonical edges');
  check(JSON.stringify(lineages)===JSON.stringify(systems.lineages),'Generated lineage data is an exact serialization of canonical systems lineages');
  const requiredSpreads=['Disclosure','First Contact','Rabbit Hole','Timeline','Prison Planet','Ascension'];
  check(requiredSpreads.every(name=>spreads.some(s=>s.title===name))&&spreads.length===12,'All required spreads are present and the collection contains twelve');
  const requiredGames=['Rabbit Hole','Ontology War','First Contact','Disclosure','Soft Disclosure','Secret Space Program','Ascension','Prison Planet'];
  check(requiredGames.every(name=>games.some(g=>g.title===name))&&games.length>=8,'All required games are present and implementable');
  check(validateGameSpecs().length===0,'All game effects use registered declarative operations',validateGameSpecs().join('; '));
  const requiredGeneratorIds=['story','character','world','faction','quest','cosmology'];
  check(requiredGeneratorIds.every(id=>systems.generators?.[id]),'Story, character, world, faction, quest, and cosmology generators are all declared',requiredGeneratorIds.filter(id=>!systems.generators?.[id]).join(', '));

  const cardFiles=(await walk(join(root,'cards'))).filter(f=>f.endsWith('.md'));
  check(cardFiles.length===cards.length,'Every Tile has one complete Markdown file',String(cardFiles.length));
  const headings=['## Origin','## What It Is','## Cultural History','## Claim and Evidence Layers','## Oracle','## Meanings','## Divination','## Game and Story Function','## Relationships','## Visual Direction','## Safety and Integration','## Sources and Research Notes'];
  const badHeadings=[];const malformedMarkdown=[];for(const file of cardFiles){const text=await readFile(file,'utf8');if(headings.some(h=>!text.includes(h)))badHeadings.push(relative(root,file));if(/\bundefined\b|\[object Object\]/.test(text))malformedMarkdown.push(relative(root,file));}
  check(!badHeadings.length,'Every Tile Markdown file contains all canonical sections',badHeadings.slice(0,10).join(', '));
  check(!malformedMarkdown.length,'No Tile Markdown exports undefined values or object stringification',malformedMarkdown.slice(0,10).join(', '));

  const manualNames=['INTRODUCTION','COSMOLOGY','HOW-TO-USE','SCIENCE-FICTION','DISCLOSURE','STARSEEDS','SECRET-SPACE-PROGRAM','HIGH-STRANGENESS','CONSCIOUSNESS','TECHNOLOGIES','BEINGS','WORLDS','FACTIONS','PROGRAMS','CONTROL-SYSTEMS','BLOODLINES-AND-LINEAGES','BIOHACKING','TIME','SIMULATION','ASCENSION','POST-SCARCITY','MEANING-MAKING','DIVINATION','SPREADS','GAME-SYSTEMS','STORY-GENERATION','GRAPH','GLOSSARY'];
  check((await Promise.all(manualNames.map(n=>exists(`manual/${n}.md`)))).every(Boolean),'All 28 required manual chapters exist');
  const manualDepth=await documentDepthFailures('manual',manualNames,name=>({words:300,headings:5,levelTwoHeadings:4,proseWords:name==='GLOSSARY'?0:80}));
  check(!manualDepth.issues.length,'All 28 manual chapters meet substantive length, section, and authored-prose gates',manualDepth.issues.slice(0,20).join('; '));
  const designNames=['VISUAL-CONSTITUTION','TILE-SYSTEM','ENAMEL-SPECIFICATION','BORDERS','ICONOGRAPHY','COLOR','METALS','TYPOGRAPHY','CARD-BACK','PACKAGING','IMAGE-GENERATION-GUIDE'];
  check((await Promise.all(designNames.map(n=>exists(`design/${n}.md`)))).every(Boolean),'All 11 required visual specifications exist');
  const designDepth=await documentDepthFailures('design',designNames,{words:250,headings:7,levelTwoHeadings:6,proseWords:75});
  check(!designDepth.issues.length,'All 11 visual specifications meet substantive production-rule and quality-gate depth',designDepth.issues.slice(0,20).join('; '));
  check(await exists('research/TAXONOMY-JUSTIFICATION.md')&&await exists('research/CURATION-METHOD.md')&&await exists('research/EPISTEMIC-AND-SAFETY.md')&&await exists('research/FUTURE-ESOTERIC-SOURCE-NOTE.md'),'Research, curation, safety, taxonomy, and unavailable-corpus notes exist');
  const researchContracts={
    'TAXONOMY-JUSTIFICATION':{words:400,headings:5,levelTwoHeadings:4,proseWords:200},
    'CURATION-METHOD':{words:250,headings:5,levelTwoHeadings:4,proseWords:170},
    'SOURCE-REGISTRY':{words:2000,headings:20,levelTwoHeadings:19,proseWords:150},
    'EPISTEMIC-AND-SAFETY':{words:220,headings:5,levelTwoHeadings:4,proseWords:200},
    'CONTRADICTION-MODEL':{words:190,headings:5,levelTwoHeadings:4,proseWords:180},
    'COVERAGE-AUDIT':{words:280,headings:5,levelTwoHeadings:4,proseWords:250},
    'FUTURE-ESOTERIC-SOURCE-NOTE':{words:250,headings:5,levelTwoHeadings:4,proseWords:220}
  };
  const researchDepth=await documentDepthFailures('research',Object.keys(researchContracts),name=>researchContracts[name]);
  check(!researchDepth.issues.length,'Key research documents meet role-appropriate depth and section gates',researchDepth.issues.slice(0,20).join('; '));

  const allFiles=await walk(root);const brokenLinks=await validateMarkdownLinks(allFiles);
  check(!brokenLinks.length,'All local Markdown links resolve',brokenLinks.slice(0,20).join('; '));
  const contaminations=allFiles.filter(f=>/(^|\/)\.DS_Store$| \d+(?:\.[^/]+)?$|\.tmp$|\.bak$/.test(f));
  check(!contaminations.length,'No duplicate-suffix, temporary, backup, or .DS_Store files contaminate the package',contaminations.map(f=>relative(root,f)).join(', '));

  check(references.length===referenceNames.length&&referenceNames.every(name=>references.some(r=>r.name===name)),'Every required reference subject has an original enamel reference',String(references.length));
  const badArt=[];const svgHashes=new Set();
  for(const ref of references){
    for(const size of [2048,1024,512,256]){const path=join(root,ref.formats[`png${size}`]);try{const bytes=await readFile(path);const dim=pngDimensions(bytes);if(!dim||dim.width!==size||dim.height!==size)badArt.push(`${ref.name}:${size}:dimensions`);if(![4,6].includes(dim?.colorType))badArt.push(`${ref.name}:${size}:no-alpha-channel`);if(bytes.length<2048)badArt.push(`${ref.name}:${size}:suspiciously-small`);}catch{badArt.push(`${ref.name}:${size}:missing`);}}
    try{const svg=await readFile(join(root,ref.formats.svg),'utf8');if(svg.length<1500)badArt.push(`${ref.name}:svg:suspiciously-small`);if(/<text\b|<(?:image|script)\b|(?:href|xlink:href)=["']https?:/i.test(svg))badArt.push(`${ref.name}:svg:embedded-or-external-content`);svgHashes.add(createHash('sha256').update(svg).digest('hex'));}catch{badArt.push(`${ref.name}:svg:missing`);}
  }
  check(!badArt.length,'All reference Tiles include SVG and valid 2048/1024/512/256 PNG files',badArt.slice(0,15).join(', '));
  check(svgHashes.size===references.length,'Every reference Tile has a unique canonical SVG image',`${svgHashes.size}/${references.length}`);
  check(references.every(r=>r.shape==='square-rounded-corner'&&!r.textEmbedded),'All reference Tiles follow the text-free rounded-square enamel system');
  const badSiteArt=[];
  for(const ref of references){
    const sourcePath=join(root,ref.formats.svg);const sitePath=join(root,'site','assets','reference-tiles',`${ref.cardId}.svg`);
    try{const [sourceBytes,siteBytes]=await Promise.all([readFile(sourcePath),readFile(sitePath)]);if(!sourceBytes.equals(siteBytes))badSiteArt.push(`${ref.cardId}:hash-mismatch`);}catch{badSiteArt.push(`${ref.cardId}:missing`);}
  }
  check(!badSiteArt.length,'Every Site reference image is an exact copy of its canonical SVG',badSiteArt.slice(0,15).join(', '));

  const [siteHtml,siteJs,siteData]=await Promise.all(['site/index.html','site/app.js','site/oracle-data.js'].map(p=>readFile(join(root,p),'utf8')));
  check(/oracle-data\.js/.test(siteHtml)&&/app\.js/.test(siteHtml)&&/styles\.css/.test(siteHtml),'Static Site loads bundled style, data, and application assets');
  check(!/\bfetch\s*\(|https?:\/\//.test(siteJs)&&siteData.startsWith('window.ORACLE_DATA='),'Core Site requires no fetch, CDN, server, or network connection');
  check(['tiles','graph','lineages','divination','spreads','games','story','research','visual','downloads'].every(route=>siteJs.includes(route)),'All requested Site views are implemented');
  check(await exists('OPEN-ORACLE.command'),'One-click local launcher exists');
  const buildManifest=await load('exports/manifest.json');const manifestFailures=[];
  const manifestPaths=new Set((buildManifest.files??[]).map(item=>item.path));
  for(const item of buildManifest.files??[]){try{const bytes=await readFile(join(root,item.path));const hash=createHash('sha256').update(bytes).digest('hex');if(bytes.length!==item.bytes||hash!==item.sha256)manifestFailures.push(`${item.path}:hash-or-size`);}catch{manifestFailures.push(`${item.path}:missing`);}}
  check(['README.md','package.json','OPEN-ORACLE.command'].every(path=>manifestPaths.has(path))&&[...manifestPaths].some(path=>path.startsWith('scripts/'))&&[...manifestPaths].some(path=>path.startsWith('site-source/')),'Build manifest covers root launch files, source scripts, and Site source');
  check(!manifestFailures.length,'Every build-manifest checksum and byte count reproduces',manifestFailures.slice(0,20).join(', '));

  const simulationStarted=Date.now();
  const divination=simulateDivination(cards,{iterations:10000,seed:'release-divination-10000'});
  const generators=simulateGenerators(cards,{iterations:10000,seed:'release-generators-10000',relationships:{edges:relationships,adjacency:systems.adjacency}});
  const gameSimulation=simulateGames({iterations:10000,seed:'release-games-10000'});
  check(divination.iterations===10000&&divination.coverage===1&&!divination.unselectedCardIds.length,'10,000 seeded divination simulations complete with full card coverage');
  const missingGeneratorSimulationIds=requiredGeneratorIds.filter(id=>!(generators.generatorHits?.[id]>0));
  check(generators.iterations===10000&&generators.coverage===1&&generators.graphGuidedRate>0.04&&!generators.specificationFailures.length&&!missingGeneratorSimulationIds.length,'10,000 reviewed-semantic-edge-guided story, character, world, faction, quest, and cosmology simulations complete',`coverage=${generators.coverage}; reviewedSemanticGraphGuided=${generators.graphGuidedRate}; missing=${missingGeneratorSimulationIds.join(',')}; failures=${generators.specificationFailures.join('; ')}`);
  check(gameSimulation.iterations===10000&&!gameSimulation.specificationFailures.length&&Object.keys(gameSimulation.gameHits).length===games.length,'10,000 game simulations cover every game without specification failure');
  const deterministicA=simulateDivination(cards,{iterations:100,seed:'repeatability'});const deterministicB=simulateDivination(cards,{iterations:100,seed:'repeatability'});
  check(deterministicA.checksum===deterministicB.checksum,'Identical seeds reproduce identical readings');

  const schemaNames=(await readdir(join(root,'schemas'))).filter(n=>n.endsWith('.json'));
  const schemaRegistry={};
  for(const schema of schemaNames){try{schemaRegistry[schema]=JSON.parse(await readFile(join(root,'schemas',schema),'utf8'));passed.push({message:`Schema parses: ${schema}`,detail:''});}catch(e){failures.push({message:`Schema parses: ${schema}`,detail:e.message});}}
  const schemaErrors=[];
  const validateRecords=(records,schemaName,label)=>records.forEach((record,index)=>validateSchema(record,schemaRegistry[schemaName],`${label}[${index}]`,schemaRegistry,schemaErrors));
  validateRecords(cards,'card.schema.json','cards');
  validateRecords(relationships,'relationship.schema.json','relationships');
  validateRecords(lineages,'lineage.schema.json','lineages');
  validateRecords(spreads,'spread.schema.json','spreads');
  const gameEffects=games.flatMap(game=>(game.turn??[]).flatMap(action=>action.effects??[]));
  validateRecords(gameEffects,'game-effect.schema.json','gameEffects');
  const classSchemaMap={factions:'faction.schema.json',technologies:'technology.schema.json',worlds:'world.schema.json'};
  for(const [classId,schemaName] of Object.entries(classSchemaMap)){
    const classRecords=await load(`data/${classId}.json`);
    validateRecords(classRecords,schemaName,`data/${classId}.json`);
    check(JSON.stringify(classRecords)===JSON.stringify(cards.filter(card=>card.class===classId)),`Class export is an exact canonical subset: ${classId}`);
  }
  check(!schemaErrors.length,'Cards, relationships, lineages, spreads, game effects, and class exports conform to their JSON Schemas',schemaErrors.slice(0,30).join('; '));
  const downloadCopies=[];
  for(const name of ['cards.json','longlist.json','relationships.json','lineages.json','spreads.json','games.json','sources.json','reference-tiles.json']){
    try{const [canonical,download]=await Promise.all([readFile(join(root,'data',name)),readFile(join(root,'site','downloads',name))]);if(!canonical.equals(download))downloadCopies.push(`${name}:mismatch`);}catch{downloadCopies.push(`${name}:missing`);}
  }
  check(!downloadCopies.length,'Every downloadable JSON export exactly matches its canonical data file',downloadCopies.join(', '));
  for(const dataFile of (await readdir(join(root,'data'))).filter(n=>n.endsWith('.json'))){try{JSON.parse(await readFile(join(root,'data',dataFile),'utf8'));}catch(e){failures.push({message:`Data JSON parses: ${dataFile}`,detail:e.message});}}
  check(!failures.some(f=>f.message.startsWith('Data JSON parses:')),'All machine-readable data files parse as JSON');

  const report={
    name:'Ultimate Science Fiction Oracle',version:'1.0.0',validatedAt:new Date().toISOString(),status:failures.length?'failed':'passed',
    counts:{candidates:longlist.length,coreCandidates:decisions.core,nonCoreCandidates:nonCore.length,candidateDispositions:decisions,cards:cards.length,relationships:relationships.length,lineages:lineages.length,spreads:spreads.length,games:games.length,generators:requiredGeneratorIds.length,referenceTiles:references.length,sources:sources.length,cardMarkdown:cardFiles.length},
    graph:{minimumDegree:systems.graphDiagnostics.minimumDegree,minimumEdgeFamilies:systems.graphDiagnostics.minimumEdgeFamilies,orphans:systems.graphDiagnostics.orphanIds.length,substantiveEdges:systems.graphDiagnostics.substantiveEdgeCount,reviewedSubstantiveEdges:systems.graphDiagnostics.reviewedSubstantiveEdgeCount,reviewedSubstantiveCoverage:systems.graphDiagnostics.reviewedSubstantiveCoverage,missingReviewedSubstantiveCards:systems.graphDiagnostics.missingReviewedSubstantiveIds.length,semanticFailures:systems.graphDiagnostics.semanticFailures.length,invalidSourcedEdgeCitations:systems.graphDiagnostics.invalidSourcedEdgeCitations.length,edgesByDerivation:systems.graphDiagnostics.edgesByDerivation},
    documents:{manuals:{count:manualNames.length,metrics:manualDepth.metrics},design:{count:designNames.length,metrics:designDepth.metrics},research:{count:Object.keys(researchContracts).length,metrics:researchDepth.metrics}},
    simulations:{elapsedMs:Date.now()-simulationStarted,divination,generators,games:gameSimulation,generatorKinds:requiredGeneratorIds,generatorHits:generators.generatorHits},
    passedChecks:passed.length,failures
  };
  await mkdir(join(root,'exports'),{recursive:true});
  await writeFile(join(root,'exports','validation-report.json'),`${JSON.stringify(report,null,2)}\n`);
  await writeFile(join(root,'exports','VALIDATION-REPORT.md'),`# Validation Report\n\n**Status: ${report.status.toUpperCase()}** · ${report.validatedAt}\n\n- ${longlist.length.toLocaleString()} evaluated candidates: ${decisions.core} core and ${nonCore.length.toLocaleString()} independent non-core\n- Natural candidate dispositions: ${Object.entries(decisions).map(([name,count])=>`${name} ${count}`).join('; ')}\n- ${cards.length} complete core Tiles and Markdown files\n- ${relationships.length.toLocaleString()} canonical typed edges; minimum degree ${report.graph.minimumDegree}; minimum families ${report.graph.minimumEdgeFamilies}; no orphans\n- ${report.graph.reviewedSubstantiveEdges} reviewed substantive edges cover ${(report.graph.reviewedSubstantiveCoverage*100).toFixed(2)}% of Tiles; uncovered Tiles are tracked rather than padded\n- ${lineages.length} lineages, ${spreads.length} spreads, ${games.length} games\n- Six generators: story, character, world, faction, quest, and cosmology\n- ${manualNames.length} manual chapters, ${designNames.length} design specifications, and ${Object.keys(researchContracts).length} depth-checked research documents\n- ${references.length} original multi-resolution reference Tiles\n- 10,000 divination + 10,000 six-kind generator + 10,000 reducer-backed game simulations\n- ${passed.length} passed checks\n- ${failures.length} failures\n\n${failures.length?'## Failures\n\n'+failures.map(f=>`- **${f.message}:** ${f.detail}`).join('\n'):'All release gates passed.'}\n`);
  const releaseManifestFiles=(await walk(root)).filter(file=>!/[\\/]exports[\\/]RELEASE-MANIFEST\.(?:json|md)$/.test(file));
  const releaseManifestItems=[];
  for(const file of releaseManifestFiles){const bytes=await readFile(file);releaseManifestItems.push({path:relative(root,file),bytes:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex')});}
  releaseManifestItems.sort((a,b)=>a.path.localeCompare(b.path));
  const releaseManifest={name:'Ultimate Science Fiction Oracle',version:'1.0.0',createdAt:new Date().toISOString(),excludes:['exports/RELEASE-MANIFEST.json','exports/RELEASE-MANIFEST.md','ultimate-science-fiction-oracle.zip'],files:releaseManifestItems};
  await writeFile(join(root,'exports','RELEASE-MANIFEST.json'),`${JSON.stringify(releaseManifest,null,2)}\n`);
  await writeFile(join(root,'exports','RELEASE-MANIFEST.md'),`# Release Manifest\n\n${releaseManifestItems.length.toLocaleString()} files are included in the verified project tree. The JSON companion records byte size and SHA-256 for every file except the two self-referential release-manifest files and the sibling ZIP.\n`);
  console.log(JSON.stringify({status:report.status,passedChecks:passed.length,failures,counts:report.counts,graph:report.graph,documents:{manuals:manualNames.length,design:designNames.length,research:Object.keys(researchContracts).length},simulationMs:report.simulations.elapsedMs,generatorHits:generators.generatorHits,simulationChecksums:{divination:divination.checksum,generators:generators.checksum,games:gameSimulation.checksum}},null,2));
  if(failures.length)process.exitCode=1;
}

await main();
