/**
 * THE BAND's data contract and treasure map, verified without a browser.
 *
 * The synthesis needs an AudioContext and is verified spectrally in the dev build; what CAN be
 * pinned here is the part that would silently rot: the station schedule, and the promise that
 * the NUMBERS station's coordinates actually lead somewhere.
 */
import { describe, expect, it } from 'vitest';
import { BAND_RANGE, type Band } from './radio';
import type { StationDef } from './stations';
import stationData from '../../data/stations.json';
import seamData from '../../data/seams.json';

const STATIONS = (stationData as { stations: StationDef[] }).stations;
const SEAMS = (seamData as {
  seams: { id: string; rect: { x: number; y: number; w: number; h: number }; richness: number; hidden?: boolean }[];
}).seams;

/** LANE-DATA's encoding: [x+32 as 2 digits][y+32 as 2 digits][richness*9 as 1 digit]. */
function decode(group: string) {
  return {
    x: Number(group.slice(0, 2)) - 32,
    y: Number(group.slice(2, 4)) - 32,
    richness: Number(group[4]) / 9,
  };
}

describe('the dial is well formed', () => {
  it('has all four stations', () => {
    expect(STATIONS.map((s) => s.id).sort()).toEqual(
      ['dead-air', 'fktry-fm', 'numbers', 'the-standard'].sort(),
    );
  });

  it('puts every station inside its own band, or the tuner could never reach it', () => {
    for (const s of STATIONS) {
      const [lo, hi] = BAND_RANGE[s.band as Band];
      expect(s.freq, `${s.id} @ ${s.freq} outside ${s.band} ${lo}-${hi}`).toBeGreaterThanOrEqual(lo);
      expect(s.freq).toBeLessThanOrEqual(hi);
    }
  });

  it('never puts two stations on the same spot of the same band', () => {
    const seen = new Set<string>();
    for (const s of STATIONS) {
      const k = `${s.band}:${s.freq}`;
      expect(seen.has(k), `duplicate ${k}`).toBe(false);
      seen.add(k);
    }
  });
});

describe('the numbers station is an honest treasure map', () => {
  const numbers = STATIONS.find((s) => s.id === 'numbers')!.numbers!;
  const hidden = SEAMS.filter((s) => s.hidden);

  it('plants exactly the hidden seams, and they decode to the real tiles', () => {
    expect(numbers.plantedGroups).toHaveLength(hidden.length);
    const decoded = numbers.plantedGroups.map(decode);
    for (const h of hidden) {
      const hit = decoded.find((d) => d.x === h.rect.x && d.y === h.rect.y);
      expect(hit, `no broadcast group leads to ${h.id} at ${h.rect.x},${h.rect.y}`).toBeTruthy();
      expect(hit!.richness).toBeCloseTo(h.richness, 1);
    }
  });

  it('keeps every group the same width, so a listener can chunk them by ear', () => {
    for (const g of [...numbers.plantedGroups, ...numbers.decoyPool, numbers.preamble]) {
      expect(g).toHaveLength(numbers.groupSize);
      expect(g).toMatch(/^\d+$/);
    }
  });

  it('no decoy ever leaks a HIDDEN seam — the one that would break the hunt', () => {
    // This is the invariant THE SPEAKER must protect: the only groups that lead to secret
    // ground are the planted ones. A decoy landing on an already-visible seam is untidy but
    // harmless (see NOTES: two currently do, reported to LANE-DATA); a decoy landing on a
    // hidden seam would hand out the treasure for free.
    for (const g of numbers.decoyPool) {
      const d = decode(g);
      const leak = hidden.find(
        (s) => d.x >= s.rect.x && d.x < s.rect.x + s.rect.w && d.y >= s.rect.y && d.y < s.rect.y + s.rect.h,
      );
      expect(leak, `decoy ${g} gives away hidden seam ${leak?.id} at ${d.x},${d.y}`).toBeUndefined();
    }
  });

  it('broadcasts a planted group often enough to be findable', () => {
    // Every 4th message, 8 groups a message, 1.2s a group: a planted coordinate roughly every
    // couple of minutes. Long enough to feel like a signal, short enough to catch one.
    const secondsPerMessage =
      (numbers.groupsPerMessage + numbers.preambleRepeats) * numbers.secondsPerGroup;
    expect(secondsPerMessage * numbers.plantEveryMessages).toBeLessThan(120);
  });
});

describe('THE STANDARD has something to say', () => {
  const lines = STATIONS.find((s) => s.id === 'the-standard')!.chyron!;

  it('carries enough copy that it does not obviously loop', () => {
    expect(lines.length).toBeGreaterThanOrEqual(20);
    expect(new Set(lines).size).toBe(lines.length);
  });

  it('fits the chyron — long lines would run off the sky', () => {
    // The chyron band renders ~62 monospace chars at 20px across 640px.
    for (const l of lines) expect(l.length, `too long to subtitle: "${l}"`).toBeLessThanOrEqual(96);
  });
});
