/**
 * Original question packs: load from disk, validate, and convert to the shape stored in the
 * Question table / fed to the engine.
 *
 * Layout: src/content/packs/<packKey>/pack.json (manifest) + <categoryKey>.json (questions).
 * Multiple-choice options are re-ordered by a hash of the question's content key, so the position
 * a writer happened to put the right answer in never leaks to players; True/False keeps its order.
 */
import fs from 'node:fs';
import path from 'node:path';
import type { z } from 'zod';
import { seededShuffle } from './rng';
import {
  PackManifestSchema,
  QUESTION_FIELDS,
  QuestionFileSchema,
  RawQuestionSchema,
  normalizeText,
  type PackManifest,
  type RawQuestion,
} from './schema';

export * from './schema';
export { fnv1a, mulberry32, seededShuffle } from './rng';

export const PACKS_DIR = path.join(import.meta.dirname, 'packs');

/** Categories whose every question may be used as the board's Q10 wildcard. */
export const WILDCARD_CATEGORIES: ReadonlySet<string> = new Set(['general', 'behind-the-music']);

export const OPTION_IDS = ['a', 'b', 'c', 'd'] as const;

export type ContentDifficulty = RawQuestion['difficulty'];
export type ContentQuestionType = RawQuestion['type'];

export interface ContentCategory {
  key: string;
  label: string;
}

/** A validated question, ready to upsert into `Question` (keyed by `contentKey`). */
export interface ContentQuestion {
  /** '<packKey>/<id>', e.g. 'originals-v1/science-001'. Stable across re-seeds. */
  contentKey: string;
  packKey: string;
  /** The writer's id within the pack, e.g. 'science-001'. */
  sourceId: string;
  category: string;
  type: ContentQuestionType;
  difficulty: ContentDifficulty;
  prompt: string;
  options: { id: string; text: string }[];
  correctOptionId: string;
  explanation: string;
  wildcardEligible: boolean;
}

export interface ContentPack {
  key: string;
  name: string;
  description: string;
  kind: PackManifest['kind'];
  /** Ordered board categories from the manifest. */
  categories: ContentCategory[];
  questions: ContentQuestion[];
  /** Manifest categories with no question file yet. */
  missingCategories: string[];
}

export interface ContentIssue {
  packKey: string;
  /** Path relative to the packs directory, e.g. 'originals-v1/science.json'. */
  file: string;
  questionId?: string;
  message: string;
}

export interface ValidationResult {
  /** Packs whose manifest is valid, with every question that passed validation. */
  packs: ContentPack[];
  errors: ContentIssue[];
  warnings: ContentIssue[];
}

export interface LoadOptions {
  /** Packs directory (defaults to the bundled src/content/packs). */
  dir?: string;
}

export class ContentValidationError extends Error {
  override readonly name = 'ContentValidationError';
  constructor(readonly errors: ContentIssue[]) {
    super(`Question packs failed validation:\n${errors.map(formatIssue).join('\n')}`);
  }
}

export function formatIssue(issue: ContentIssue): string {
  return `${issue.file}${issue.questionId ? ` [${issue.questionId}]` : ''}: ${issue.message}`;
}

function zodMessages(error: z.ZodError): string[] {
  return error.issues.map((i) => (i.path.length ? `${i.path.join('.')}: ${i.message}` : i.message));
}

function readJson(file: string): { ok: true; value: unknown } | { ok: false; message: string } {
  let text: string;
  try {
    text = fs.readFileSync(file, 'utf8');
  } catch (err) {
    return { ok: false, message: `Cannot read file: ${(err as Error).message}` };
  }
  try {
    // Tolerate a UTF-8 BOM (common from Windows editors).
    return { ok: true, value: JSON.parse(text.replace(/^﻿/, '')) };
  } catch (err) {
    return { ok: false, message: `Invalid JSON: ${(err as Error).message}` };
  }
}

/** Convert one validated raw question into its stored form (deterministic). */
export function toContentQuestion(packKey: string, category: string, raw: RawQuestion): ContentQuestion {
  const contentKey = `${packKey}/${raw.id}`;
  const order =
    raw.type === 'MULTIPLE_CHOICE' ? seededShuffle(raw.options.map((_, i) => i), contentKey) : raw.options.map((_, i) => i);
  const options = order.map((sourceIndex, i) => ({ id: OPTION_IDS[i] ?? String(i), text: raw.options[sourceIndex] ?? '' }));
  const correct = options[order.indexOf(raw.answer)];
  if (!correct) throw new Error(`${contentKey}: answer index ${raw.answer} out of range`);
  return {
    contentKey,
    packKey,
    sourceId: raw.id,
    category,
    type: raw.type,
    difficulty: raw.difficulty,
    prompt: raw.prompt,
    options,
    correctOptionId: correct.id,
    explanation: raw.explanation,
    wildcardEligible: WILDCARD_CATEGORIES.has(category) || raw.wildcard,
  };
}

function validatePack(dir: string, packKey: string, errors: ContentIssue[], warnings: ContentIssue[]): ContentPack | null {
  const packDir = path.join(dir, packKey);
  const manifestFile = `${packKey}/pack.json`;
  const manifestJson = readJson(path.join(packDir, 'pack.json'));
  if (!manifestJson.ok) {
    errors.push({ packKey, file: manifestFile, message: manifestJson.message });
    return null;
  }
  const manifest = PackManifestSchema.safeParse(manifestJson.value);
  if (!manifest.success) {
    for (const message of zodMessages(manifest.error)) errors.push({ packKey, file: manifestFile, message });
    return null;
  }
  if (manifest.data.key !== packKey) {
    errors.push({ packKey, file: manifestFile, message: `key "${manifest.data.key}" must match its folder name "${packKey}"` });
    return null;
  }

  const categoryKeys = new Set(manifest.data.categories.map((c) => c.key));
  const questionFiles = fs
    .readdirSync(packDir, { withFileTypes: true })
    .filter((e) => e.isFile() && e.name.endsWith('.json') && e.name !== 'pack.json')
    .map((e) => e.name)
    .sort();

  const questions: ContentQuestion[] = [];
  const seenIds = new Map<string, string>(); // id -> file
  const seenPrompts = new Map<string, string>(); // normalized prompt -> contentKey
  const presentCategories = new Set<string>();

  for (const fileName of questionFiles) {
    const file = `${packKey}/${fileName}`;
    const categoryKey = fileName.slice(0, -'.json'.length);
    if (!categoryKeys.has(categoryKey)) {
      errors.push({ packKey, file, message: `"${categoryKey}" is not a category in ${packKey}/pack.json` });
      continue;
    }
    const json = readJson(path.join(packDir, fileName));
    if (!json.ok) {
      errors.push({ packKey, file, message: json.message });
      continue;
    }
    const envelope = QuestionFileSchema.safeParse(json.value);
    if (!envelope.success) {
      for (const message of zodMessages(envelope.error)) errors.push({ packKey, file, message });
      continue;
    }
    if (envelope.data.pack !== packKey) {
      errors.push({ packKey, file, message: `"pack" is "${envelope.data.pack}", expected "${packKey}"` });
      continue;
    }
    if (envelope.data.category !== categoryKey) {
      errors.push({ packKey, file, message: `"category" is "${envelope.data.category}", expected "${categoryKey}" (file name)` });
      continue;
    }
    presentCategories.add(categoryKey);

    envelope.data.questions.forEach((item, index) => {
      const rawId =
        item && typeof item === 'object' && typeof (item as { id?: unknown }).id === 'string'
          ? (item as { id: string }).id
          : `questions[${index}]`;
      const parsed = RawQuestionSchema.safeParse(item);
      if (!parsed.success) {
        for (const message of zodMessages(parsed.error)) errors.push({ packKey, file, questionId: rawId, message });
        return;
      }
      const raw = parsed.data;
      const unknownKeys = Object.keys(item as object).filter((k) => !(QUESTION_FIELDS as readonly string[]).includes(k));
      if (unknownKeys.length > 0) {
        warnings.push({ packKey, file, questionId: raw.id, message: `Ignoring unknown field(s): ${unknownKeys.join(', ')}` });
      }
      if (!raw.id.startsWith(`${categoryKey}-`)) {
        errors.push({ packKey, file, questionId: raw.id, message: `id must start with "${categoryKey}-"` });
        return;
      }
      const previousFile = seenIds.get(raw.id);
      if (previousFile !== undefined) {
        errors.push({ packKey, file, questionId: raw.id, message: `Duplicate id (also in ${previousFile})` });
        return;
      }
      seenIds.set(raw.id, file);
      const promptKey = normalizeText(raw.prompt);
      const duplicateOf = seenPrompts.get(promptKey);
      if (duplicateOf !== undefined) {
        errors.push({ packKey, file, questionId: raw.id, message: `Duplicate prompt (same as ${duplicateOf})` });
        return;
      }
      const question = toContentQuestion(packKey, categoryKey, raw);
      seenPrompts.set(promptKey, question.contentKey);
      questions.push(question);
    });
  }

  const missingCategories = manifest.data.categories.map((c) => c.key).filter((k) => !presentCategories.has(k));
  if (missingCategories.length === manifest.data.categories.length) {
    warnings.push({ packKey, file: `${packKey}/`, message: 'No question files yet' });
  } else {
    for (const key of missingCategories) {
      warnings.push({ packKey, file: `${packKey}/${key}.json`, message: 'Category has no question file yet' });
    }
  }

  return {
    key: manifest.data.key,
    name: manifest.data.name,
    description: manifest.data.description,
    kind: manifest.data.kind,
    categories: manifest.data.categories.map((c) => ({ key: c.key, label: c.label })),
    questions,
    missingCategories,
  };
}

/** Validate every pack under the packs directory. Never throws for content problems. */
export function validatePacks(options: LoadOptions = {}): ValidationResult {
  const dir = options.dir ?? PACKS_DIR;
  const errors: ContentIssue[] = [];
  const warnings: ContentIssue[] = [];
  const packs: ContentPack[] = [];
  const packKeys = fs
    .readdirSync(dir, { withFileTypes: true })
    .filter((e) => e.isDirectory())
    .map((e) => e.name)
    .sort();
  for (const packKey of packKeys) {
    if (!fs.existsSync(path.join(dir, packKey, 'pack.json'))) {
      warnings.push({ packKey, file: `${packKey}/`, message: 'Folder has no pack.json; skipped' });
      continue;
    }
    const pack = validatePack(dir, packKey, errors, warnings);
    if (pack) packs.push(pack);
  }
  return { packs, errors, warnings };
}

/** Load every pack; throws ContentValidationError if any file is invalid. */
export function loadPacks(options: LoadOptions = {}): ContentPack[] {
  const result = validatePacks(options);
  if (result.errors.length > 0) throw new ContentValidationError(result.errors);
  return result.packs;
}
