import type { Slice } from '../sim/slice';
import { CHAR_THRESHOLD } from '../sim/slice';
import { SPREADS, amountClassOf } from '../sim/spreads';
import { TOOLS, type ToolId } from '../sim/cutlery';
import type { Order } from './orders';

export interface Criterion {
  key: string;
  label: string;
  /** 0..1 */
  score: number;
  weight: number;
  /** What the number actually was, in words. */
  detail: string;
}

export type Grade = 'S' | 'A' | 'B' | 'C' | 'F';

export interface Verdict {
  criteria: Criterion[];
  /** 0..10 */
  total: number;
  grade: Grade;
  best: Criterion;
  worst: Criterion;
  lines: string[];
}

const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);

/**
 * Every number here is read off the same fields the player was pushing around
 * with the knife, so the scorecard can always point at something real.
 */
export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: number): Verdict {
  const b = slice.browning.stats(slice.mask);
  const s = slice.spread.stats(slice.mask);
  const d = slice.damage.stats(slice.mask);
  const def = SPREADS[order.spread];
  const [lo, hi] = def.amounts[order.amount];
  const target = (lo + hi) / 2;

  const charFrac = slice.browning.fraction(slice.mask, (v) => v > CHAR_THRESHOLD);
  const coverage = slice.spread.fraction(slice.mask, (v) => v >= lo * 0.75);
  // Uniformity only over the bits you actually covered — punishing the variance
  // of a half-spread slice twice (here and in coverage) isn't fair.
  const covered = spreadStdevOverCovered(slice, lo * 0.75);

  const criteria: Criterion[] = [
    {
      key: 'browning',
      label: 'Browning',
      score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3),
      weight: 1.5,
      detail: `${b.mean.toFixed(2)} against ${order.browning.toFixed(2)} asked`,
    },
    {
      key: 'evenness',
      label: 'Evenness',
      score: clamp01(1 - b.stdev / 0.2),
      weight: 1.1,
      detail: `variation ${b.stdev.toFixed(3)}`,
    },
    {
      key: 'coverage',
      label: 'Coverage',
      score: clamp01((coverage - 0.15) / 0.75),
      weight: 1.3,
      detail: `${Math.round(coverage * 100)}% of the slice`,
    },
    {
      key: 'uniformity',
      label: 'Uniformity',
      score: clamp01(1 - covered / 0.22),
      weight: 0.9,
      detail: `variation ${covered.toFixed(3)}`,
    },
    {
      key: 'char',
      label: 'Char',
      score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45),
      weight: order.noChar ? 1.3 : 0.5,
      detail: charFrac < 0.005 ? 'none' : `${Math.round(charFrac * 100)}% burnt`,
    },
    {
      key: 'integrity',
      label: 'Integrity',
      score: clamp01(1 - d.mean / 0.06),
      weight: 1.2,
      detail: d.mean < 0.002 ? 'intact' : `torn over ${Math.round(slice.damage.fraction(slice.mask, (v) => v > 0.02) * 100)}%`,
    },
    ...(def.particles ? [rindCriterion(slice, def.particles.name)] : []),
    ...(def.separates ? [consistencyCriterion(slice)] : []),
    {
      key: 'amount',
      label: 'The Right Amount',
      score: amountScore(s.mean, lo, hi, target),
      weight: 1.4,
      detail: `${amountWord(slice, order)} — ${order.amount} asked`,
    },
    {
      key: 'tool',
      label: 'Utensil',
      score: usedTool === order.tool ? 1 : TOOLS[usedTool].ideal ? 0.6 : 0.25,
      weight: 0.4,
      detail: usedTool === order.tool ? TOOLS[usedTool].name : `${TOOLS[usedTool].name}, not the ${TOOLS[order.tool].name}`,
    },
    {
      key: 'time',
      label: 'Service',
      score: clamp01(1 - (seconds - 45) / 90),
      weight: 0.3,
      detail: `${Math.round(seconds)}s`,
    },
  ];

  let sum = 0;
  let wsum = 0;
  for (const c of criteria) {
    sum += c.score * c.weight;
    wsum += c.weight;
  }
  const total = (sum / wsum) * 10;

  // Best/worst ignore Service — nobody wants a verdict about the clock.
  const rankable = criteria.filter((c) => c.key !== 'time');
  const sorted = [...rankable].sort((a, b2) => a.score - b2.score);
  const worst = sorted[0];
  const best = sorted[sorted.length - 1];

  return {
    criteria,
    total,
    grade: gradeOf(total),
    best,
    worst,
    lines: [],
  };
}

/**
 * How evenly the rind is strewn, scored with the Clark–Evans index: the mean
 * nearest-neighbour distance between bits, over what that distance would be if
 * the same number of bits were scattered at random. R near 0 = one clump,
 * R = 1 = random scatter, R above 1 = deliberately even. The judge accepts
 * random; he rewards deliberate.
 */
function rindCriterion(slice: Slice, name: string): Criterion {
  const pts = slice.rind;
  const n = pts.length;
  if (n < 4) {
    return {
      key: 'rind',
      label: cap(name),
      score: n === 0 ? 0.05 : 0.3,
      weight: 1.1,
      detail: n === 0 ? `no ${name} at all` : `only ${n} bits of ${name}`,
    };
  }
  // Mask area in UV units — the bread is the study region, not the unit square.
  const area = slice.mask.data.reduce((a, v) => a + (v > 0.5 ? 1 : 0), 0) / (slice.mask.n * slice.mask.n);
  let sum = 0;
  for (let i = 0; i < n; i++) {
    let best = Infinity;
    for (let j = 0; j < n; j++) {
      if (i === j) continue;
      const du = pts[i].u - pts[j].u;
      const dv = pts[i].v - pts[j].v;
      const d2 = du * du + dv * dv;
      if (d2 < best) best = d2;
    }
    sum += Math.sqrt(best);
  }
  const observed = sum / n;
  const expected = 0.5 * Math.sqrt(area / n);
  const R = observed / expected;
  const score = clamp01(R / 0.95);
  const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn';
  return {
    key: 'rind',
    label: cap(name),
    score,
    weight: 1.1,
    detail: `${n} bits, ${word} (R ${R.toFixed(2)})`,
  };
}

/**
 * How the spread that actually landed compares to a properly stirred jar.
 * 0.5 is right; the slick off the top of an unstirred jar pushes it up, the
 * dried paste underneath pulls it down. The fix was always the same: stir.
 */
function consistencyCriterion(slice: Slice): Criterion {
  const oil = slice.oilMean;
  const spread = slice.oilStdev;
  const off = Math.abs(oil - 0.5);
  // Two ways to fail: wrong on average, or right on average and wrong in every
  // individual bite. The stdev term is what stops "one oily strip, one dry
  // strip" from scoring as though the jar had been stirred.
  const score = clamp01(1 - off / 0.32 - spread / 0.24);
  const word =
    spread > 0.15
      ? 'slick here, grout there'
      : oil > 0.78
        ? 'a slick'
        : oil > 0.62
          ? 'oily'
          : oil >= 0.38
            ? 'just right'
            : oil >= 0.22
              ? 'dry'
              : 'grout';
  return {
    key: 'consistency',
    label: 'Consistency',
    score,
    weight: 1.1,
    detail: `oil ${oil.toFixed(2)} ±${spread.toFixed(2)} — ${word}`,
  };
}

function cap(s: string): string {
  return s.charAt(0).toUpperCase() + s.slice(1);
}

function spreadStdevOverCovered(slice: Slice, floor: number): number {
  const sp = slice.spread.data;
  const m = slice.mask.data;
  let sum = 0;
  let n = 0;
  for (let i = 0; i < sp.length; i++) {
    if (m[i] < 0.5 || sp[i] < floor) continue;
    sum += sp[i];
    n++;
  }
  if (n < 8) return 1;
  const mean = sum / n;
  let acc = 0;
  for (let i = 0; i < sp.length; i++) {
    if (m[i] < 0.5 || sp[i] < floor) continue;
    const d = sp[i] - mean;
    acc += d * d;
  }
  return Math.sqrt(acc / n);
}

/** Full marks anywhere inside the band, falling away outside it. */
function amountScore(mean: number, lo: number, hi: number, target: number): number {
  if (mean >= lo && mean <= hi) return 1;
  const dist = mean < lo ? lo - mean : mean - hi;
  return clamp01(1 - dist / (target * 1.5));
}

function amountWord(slice: Slice, order: Order): string {
  const def = SPREADS[order.spread];
  const mean = slice.spread.stats(slice.mask).mean;
  const cls = amountClassOf(def, mean);
  if (cls === 'none') return 'essentially none';
  if (cls === 'obscene') return 'an obscene amount';
  return cls;
}

export function gradeOf(total: number): Grade {
  if (total >= 9.2) return 'S';
  if (total >= 7.6) return 'A';
  if (total >= 5.8) return 'B';
  if (total >= 3.8) return 'C';
  return 'F';
}
