import { FIELD_ALIASES, FIELD_IDS } from "./fields.js";
import { SPRITE_ALIASES, SPRITE_IDS } from "./sprites.js";

export const TRANSITION_IDS = Object.freeze([
  "field-morph",
  "seeded-dissolve",
  "radial-cascade",
  "angular-sweep",
  "scanline",
  "contour-trace",
  "path-draw",
  "axis-flip",
  "cluster-dissolve",
  "neighbor-ignite",
  "glitch-bands",
  "instant",
]);

export const PIXEL_SWITCH_IDS = Object.freeze([
  "ordered-dither",
  "temporal-blue-noise",
  "threshold-hysteresis",
  "sdf-wavefront",
  "contour-trace",
  "curl-advect",
  "neighbor-propagation",
  "radial-cascade",
  "path-draw",
  "axis-flip",
  "seeded-dissolve",
  "field-morph",
]);

export const PIXEL_SHAPE_IDS = Object.freeze([
  "disc",
  "square",
  "diamond",
  "capsule",
  "line",
  "ring",
  "cross",
  "square-cross",
  "square-cross-ring",
]);

const TRANSITION_ALIASES = Object.freeze({
  dissolve: "seeded-dissolve",
  radial: "radial-cascade",
  bloom: "radial-cascade",
  spiral: "angular-sweep",
  wave: "scanline",
  shutter: "axis-flip",
  glitch: "glitch-bands",
  "sdf-wavefront": "contour-trace",
  "neighbor-propagation": "neighbor-ignite",
});

const PIXEL_SWITCH_ALIASES = Object.freeze({
  "cluster-dissolve": "seeded-dissolve",
  "neighbor-ignite": "neighbor-propagation",
});

const PIXEL_SHAPE_ALIASES = Object.freeze({
  circle: "disc",
  "rounded-square": "square",
  dot: "disc",
});

const ENGINE_OPTION_KEYS = Object.freeze([
  "ariaLive",
  "autoResize",
  "autoplay",
  "background",
  "canvas",
  "contrast",
  "density",
  "direction",
  "dprMax",
  "field",
  "fieldMode",
  "fps",
  "gridSize",
  "interactive",
  "nonErrorPalette",
  "offPixels",
  "orbBoundary",
  "orbBackgroundColor",
  "orbBackgroundMode",
  "package",
  "palette",
  "paletteOverride",
  "pixelShape",
  "pixelSwitch",
  "previewMode",
  "progress",
  "quality",
  "reducedMotion",
  "seed",
  "speed",
  "sprite",
  "targetGlyph",
  "transition",
  "version",
]);

const NUMBER_OPTIONS = Object.freeze({
  contrast: { minimum: 0.25, maximum: 2 },
  density: { minimum: 0.35, maximum: 1.6 },
  dprMax: { minimum: 0.25, maximum: 8 },
  fps: { minimum: 1, maximum: 120, integer: true },
  gridSize: { minimum: 8, maximum: 96, integer: true },
  progress: { minimum: 0, maximum: 1 },
  speed: { minimum: 0.05, maximum: 5 },
});

const BOOLEAN_OPTIONS = Object.freeze([
  "autoResize",
  "autoplay",
  "background",
  "interactive",
  "offPixels",
]);

const normalizeKey = (value) =>
  String(value ?? "")
    .trim()
    .toLowerCase()
    .replace(/[\s_]+/g, "-");

function distanceBetween(left, right) {
  const a = [...String(left)];
  const b = [...String(right)];
  const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
  const current = new Array(b.length + 1);

  for (let row = 1; row <= a.length; row += 1) {
    current[0] = row;
    for (let column = 1; column <= b.length; column += 1) {
      current[column] = Math.min(
        current[column - 1] + 1,
        previous[column] + 1,
        previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1),
      );
    }
    for (let column = 0; column <= b.length; column += 1) {
      previous[column] = current[column];
    }
  }
  return previous[b.length];
}

export function nearestName(value, candidates, options = {}) {
  const input = normalizeKey(value);
  if (!input || !Array.isArray(candidates) || candidates.length === 0) {
    return null;
  }
  let nearest = null;
  let nearestDistance = Infinity;
  for (const candidate of candidates) {
    const distance = distanceBetween(input, normalizeKey(candidate));
    if (distance < nearestDistance) {
      nearest = candidate;
      nearestDistance = distance;
    }
  }
  const maximum =
    options.maxDistance ?? Math.max(2, Math.floor(input.length * 0.34));
  return nearestDistance <= maximum ? nearest : null;
}

export class JoanConfigurationError extends RangeError {
  constructor(message, details = {}) {
    super(message);
    this.name = "JoanConfigurationError";
    this.code = details.code || "invalid_configuration";
    this.path = details.path || null;
    this.value = details.value;
    this.suggestion = details.suggestion || null;
    this.allowed = details.allowed ? [...details.allowed] : null;
  }

  toIssue() {
    return {
      code: this.code,
      message: this.message,
      path: this.path,
      value: this.value,
      suggestion: this.suggestion,
      severity: "error",
    };
  }
}

function aliasLookup(aliases) {
  return new Map(
    Object.entries(aliases || {}).map(([alias, canonical]) => [
      normalizeKey(alias),
      canonical,
    ]),
  );
}

export function validateKnownValue(value, allowed, options = {}) {
  const label = options.label || "value";
  const path = options.path || label;
  const normalized = normalizeKey(value);
  const canonical = new Map(
    allowed.map((candidate) => [normalizeKey(candidate), candidate]),
  );
  if (canonical.has(normalized)) return canonical.get(normalized);

  const aliases = aliasLookup(options.aliases);
  if (aliases.has(normalized)) return aliases.get(normalized);

  const accepted = [...allowed, ...Object.keys(options.aliases || {})];
  const suggestion = nearestName(value, accepted);
  const suffix = suggestion ? ` Did you mean "${suggestion}"?` : "";
  throw new JoanConfigurationError(
    `Unknown ${label} "${String(value)}".${suffix}`,
    {
      code: `unknown_${label.replaceAll(" ", "_")}`,
      path,
      value,
      suggestion,
      allowed,
    },
  );
}

export const validateSpriteId = (value) =>
  validateKnownValue(value, SPRITE_IDS, {
    aliases: SPRITE_ALIASES,
    label: "sprite",
    path: "sprite",
  });

export const validateFieldId = (value) =>
  validateKnownValue(value, FIELD_IDS, {
    aliases: FIELD_ALIASES,
    label: "field",
    path: "field",
  });

export const validateTransition = (value) =>
  validateKnownValue(
    typeof value === "string"
      ? value
      : value?.name || value?.type || value?.enter,
    TRANSITION_IDS,
    {
    aliases: TRANSITION_ALIASES,
    label: "transition",
    path: "transition",
    },
  );

export const validatePixelShape = (value) =>
  validateKnownValue(
    typeof value === "string" ? value : value?.shape || value?.name,
    PIXEL_SHAPE_IDS,
    {
    aliases: PIXEL_SHAPE_ALIASES,
    label: "pixel shape",
    path: "pixelShape",
    },
  );

export const validatePixelSwitch = (value) =>
  validateKnownValue(
    typeof value === "string"
      ? value
      : value?.mode || value?.name || value?.type,
    PIXEL_SWITCH_IDS,
    {
    aliases: PIXEL_SWITCH_ALIASES,
    label: "pixel switch",
    path: "pixelSwitch",
    },
  );

function issueFrom(error, fallbackPath) {
  if (error instanceof JoanConfigurationError) return error.toIssue();
  return {
    code: "invalid_configuration",
    message: error?.message || "Invalid engine configuration.",
    path: fallbackPath || null,
    value: undefined,
    suggestion: null,
    severity: "error",
  };
}

function inspectNumber(key, value, definition, coerce) {
  if (!coerce && typeof value !== "number") {
    throw new JoanConfigurationError(`${key} must be a number.`, {
      code: "invalid_type",
      path: key,
      value,
    });
  }
  let next = coerce ? Number(value) : value;
  if (!Number.isFinite(next)) {
    throw new JoanConfigurationError(`${key} must be a finite number.`, {
      code: "invalid_number",
      path: key,
      value,
    });
  }
  if (definition.integer && !coerce && !Number.isInteger(next)) {
    throw new JoanConfigurationError(`${key} must be an integer.`, {
      code: "invalid_number",
      path: key,
      value,
    });
  }
  if (definition.integer) next = Math.round(next);
  if (next < definition.minimum || next > definition.maximum) {
    if (!coerce) {
      throw new JoanConfigurationError(
        `${key} must be between ${definition.minimum} and ${definition.maximum}.`,
        {
          code: "out_of_range",
          path: key,
          value,
        },
      );
    }
    next = Math.min(Math.max(next, definition.minimum), definition.maximum);
  }
  return next;
}

function validatePalette(value, path, { allowVariants = true } = {}) {
  if (value === null) return null;
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new JoanConfigurationError(`${path} must be a palette object or null.`, {
      code: "invalid_type",
      path,
      value,
    });
  }
  const allowed = new Set([
    "name",
    "background",
    "shadow",
    "off",
    "ink",
    "accent",
    "glow",
    ...(allowVariants ? ["variants"] : []),
  ]);
  const next = {};
  for (const [key, color] of Object.entries(value)) {
    if (!allowed.has(key)) {
      const suggestion = nearestName(key, [...allowed]);
      throw new JoanConfigurationError(
        `Unknown ${path} channel "${key}".${
          suggestion ? ` Did you mean "${suggestion}"?` : ""
        }`,
        {
          code: "unknown_palette_channel",
          path: `${path}.${key}`,
          value: color,
          suggestion,
        },
      );
    }
    if (key === "variants") {
      if (!color || typeof color !== "object" || Array.isArray(color)) {
        throw new JoanConfigurationError(`${path}.variants must be an object.`, {
          code: "invalid_type",
          path: `${path}.variants`,
          value: color,
        });
      }
      next.variants = Object.fromEntries(
        Object.entries(color).map(([name, palette]) => [
          name,
          validatePalette(palette, `${path}.variants.${name}`, {
            allowVariants: false,
          }),
        ]),
      );
      continue;
    }
    if (typeof color !== "string" || !color.trim()) {
      throw new JoanConfigurationError(`${path}.${key} must be a CSS color string.`, {
        code: "invalid_type",
        path: `${path}.${key}`,
        value: color,
      });
    }
    next[key] = color.trim();
  }
  return next;
}

const PORTABLE_COLOR_KEYWORDS = new Set([
  "aqua",
  "black",
  "blue",
  "fuchsia",
  "gray",
  "green",
  "grey",
  "lime",
  "maroon",
  "navy",
  "olive",
  "orange",
  "purple",
  "red",
  "silver",
  "teal",
  "transparent",
  "white",
  "yellow",
]);

function validateOptionalColor(value, path) {
  if (value === null) return null;
  if (typeof value !== "string" || !value.trim()) {
    throw new JoanConfigurationError(`${path} must be a CSS color string or null.`, {
      code: "invalid_type",
      path,
      value,
    });
  }
  const candidate = value.trim();
  if (candidate.toLowerCase() === "transparent") return null;
  const functionMatch =
    /^(rgb|rgba|hsl|hsla)\(([\d\s.,%+/-]+)\)$/i.exec(candidate);
  const portable =
    /^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(candidate) ||
    PORTABLE_COLOR_KEYWORDS.has(candidate.toLowerCase()) ||
    (Boolean(functionMatch) && /\d/.test(functionMatch[2]));
  if (!portable) {
    throw new JoanConfigurationError(
      `${path} must be a portable CSS color (hex, rgb, hsl, a supported keyword) or null.`,
      {
        code: "invalid_color",
        path,
        value,
      },
    );
  }
  return candidate;
}

export function inspectEngineOptions(input, options = {}) {
  const coerce = options.coerce !== false;
  if (!input || typeof input !== "object" || Array.isArray(input)) {
    const error = new JoanConfigurationError(
      "Engine options must be an object.",
      { code: "invalid_type", path: "options", value: input },
    );
    return { ok: false, value: {}, issues: [error.toIssue()] };
  }

  const value = { ...input };
  const issues = [];
  const allowedKeys = new Set(ENGINE_OPTION_KEYS);
  if (options.allowUnknown !== true) {
    for (const key of Object.keys(value)) {
      if (allowedKeys.has(key)) continue;
      const suggestion = nearestName(key, ENGINE_OPTION_KEYS);
      issues.push({
        code: "unknown_option",
        message: `Unknown engine option "${key}".${
          suggestion ? ` Did you mean "${suggestion}"?` : ""
        }`,
        path: key,
        value: value[key],
        suggestion,
        severity: "error",
      });
      delete value[key];
    }
  }

  const apply = (key, callback) => {
    if (!Object.hasOwn(value, key) || value[key] === undefined) return;
    try {
      value[key] = callback(value[key]);
    } catch (error) {
      issues.push(issueFrom(error, key));
      delete value[key];
    }
  };

  apply("sprite", (sprite) =>
    sprite && typeof sprite === "object" ? validateRecipe(sprite) : validateSpriteId(sprite),
  );
  apply("field", (field) => (field === null ? null : validateFieldId(field)));
  apply("transition", (transition) =>
    transition === null
      ? null
      : transition && typeof transition === "object"
        ? {
            ...transition,
            name: validateTransition(
              transition.name || transition.type || transition.enter,
            ),
          }
        : validateTransition(transition),
  );
  apply("pixelShape", (shape) =>
    shape === null ? null : validatePixelShape(shape),
  );
  apply("pixelSwitch", (pixelSwitch) =>
    pixelSwitch === null
      ? null
      : pixelSwitch && typeof pixelSwitch === "object"
        ? {
            ...pixelSwitch,
            mode: validatePixelSwitch(
              pixelSwitch.mode || pixelSwitch.name || pixelSwitch.type,
            ),
          }
        : validatePixelSwitch(pixelSwitch),
  );
  apply("fieldMode", (mode) =>
    validateKnownValue(mode, ["recipe", "override"], {
      label: "field mode",
      path: "fieldMode",
    }),
  );
  apply("quality", (quality) =>
    validateKnownValue(quality, ["low", "balanced", "high", "auto"], {
      label: "quality",
      path: "quality",
    }),
  );
  apply("previewMode", (mode) =>
    validateKnownValue(mode, ["fit", "actual"], {
      label: "preview mode",
      path: "previewMode",
    }),
  );
  apply("orbBoundary", (boundary) =>
    validateKnownValue(boundary, ["defined", "gestalt"], {
      label: "orb boundary",
      path: "orbBoundary",
    }),
  );
  apply("orbBackgroundColor", (color) =>
    validateOptionalColor(color, "orbBackgroundColor"),
  );
  apply("orbBackgroundMode", (mode) =>
    validateKnownValue(mode, ["none", "solid", "pixelated"], {
      label: "orb background mode",
      path: "orbBackgroundMode",
    }),
  );
  apply("reducedMotion", (mode) => {
    if (typeof mode === "boolean" || mode === "system") return mode;
    throw new JoanConfigurationError(
      'reducedMotion must be true, false, or "system".',
      { code: "invalid_type", path: "reducedMotion", value: mode },
    );
  });

  for (const [key, definition] of Object.entries(NUMBER_OPTIONS)) {
    apply(key, (number) => inspectNumber(key, number, definition, coerce));
  }
  for (const key of BOOLEAN_OPTIONS) {
    apply(key, (boolean) => {
      if (typeof boolean === "boolean") return boolean;
      if (coerce && (boolean === "true" || boolean === "false")) {
        return boolean === "true";
      }
      throw new JoanConfigurationError(`${key} must be a boolean.`, {
        code: "invalid_type",
        path: key,
        value: boolean,
      });
    });
  }
  for (const key of ["palette", "paletteOverride", "nonErrorPalette"]) {
    apply(key, (palette) => validatePalette(palette, key));
  }

  return { ok: issues.length === 0, value, issues };
}

export function validateEngineOptions(input, options = {}) {
  const strict = options.strict !== false;
  const result = inspectEngineOptions(input, {
    ...options,
    coerce: options.coerce ?? !strict,
  });
  if (!result.ok && strict) {
    const issue = result.issues[0];
    throw new JoanConfigurationError(issue.message, issue);
  }
  return result.value;
}

export function validateRecipe(input) {
  if (!input || typeof input !== "object" || Array.isArray(input)) {
    throw new JoanConfigurationError("A recipe must be an object.", {
      code: "invalid_type",
      path: "recipe",
      value: input,
    });
  }
  if (typeof input.id !== "string" || !input.id.trim()) {
    throw new JoanConfigurationError("A recipe requires a non-empty id.", {
      code: "missing_recipe_id",
      path: "recipe.id",
      value: input.id,
    });
  }
  if (typeof input.glyph !== "string" || !input.glyph.trim()) {
    throw new JoanConfigurationError("A recipe requires a glyph id.", {
      code: "missing_recipe_glyph",
      path: "recipe.glyph",
      value: input.glyph,
    });
  }

  const recipe = { ...input, id: input.id.trim(), glyph: input.glyph.trim() };
  if (recipe.field !== undefined) recipe.field = validateFieldId(recipe.field);
  if (Array.isArray(recipe.fieldMix)) {
    recipe.fieldMix = recipe.fieldMix.map((layer, index) => {
      if (!layer || typeof layer !== "object") {
        throw new JoanConfigurationError(`recipe.fieldMix[${index}] must be an object.`, {
          code: "invalid_type",
          path: `recipe.fieldMix[${index}]`,
          value: layer,
        });
      }
      return { ...layer, field: validateFieldId(layer.field) };
    });
  } else if (!recipe.field) {
    throw new JoanConfigurationError("A recipe requires field or fieldMix.", {
      code: "missing_recipe_field",
      path: "recipe.field",
      value: recipe.field,
    });
  }

  if (recipe.pixelShape !== undefined) {
    recipe.pixelShape = validatePixelShape(recipe.pixelShape);
  }
  if (recipe.pixel?.shape !== undefined) {
    recipe.pixel = {
      ...recipe.pixel,
      shape: validatePixelShape(recipe.pixel.shape),
    };
  }
  if (recipe.pixelSwitch !== undefined) {
    if (typeof recipe.pixelSwitch === "string") {
      recipe.pixelSwitch = validatePixelSwitch(recipe.pixelSwitch);
    } else if (recipe.pixelSwitch?.mode !== undefined) {
      recipe.pixelSwitch = {
        ...recipe.pixelSwitch,
        mode: validatePixelSwitch(recipe.pixelSwitch.mode),
      };
    }
  }
  if (recipe.transition !== undefined) {
    if (typeof recipe.transition === "string") {
      recipe.transition = validateTransition(recipe.transition);
    } else if (recipe.transition && typeof recipe.transition === "object") {
      const transition = { ...recipe.transition };
      for (const key of ["name", "type", "enter", "exit"]) {
        if (transition[key] !== undefined) {
          transition[key] = validateTransition(transition[key]);
        }
      }
      recipe.transition = transition;
    }
  }
  return recipe;
}

function deepFreeze(value, seen = new WeakSet()) {
  if (!value || typeof value !== "object" || seen.has(value)) return value;
  seen.add(value);
  for (const nested of Object.values(value)) deepFreeze(nested, seen);
  return Object.freeze(value);
}

export function defineRecipe(recipe) {
  return deepFreeze(validateRecipe(recipe));
}
