/**
 * presSystem.test.js — system-level tests for the themed presentation
 * builder. Locks the invariants that make the design system reliable.
 *
 * Coverage:
 *   - Every archetype × every theme builds without throwing.
 *   - Theme resolver returns a complete shape no matter what the
 *     agent passes (empty spec, unknown theme, unknown palette).
 *   - Palette has all 8 semantic roles for every named palette.
 *   - Deck context tracks sections correctly across mixed slide types.
 *   - Background dispatch returns the right function for every named
 *     style (and falls back without throwing on unknown ones).
 *   - buildThemedPres produces a real .pptx that pres.read can parse.
 *
 * These tests are CHEAP — they build to a Buffer in memory and never
 * touch disk except where a real round-trip matters. Total runtime
 * should stay under 5s so this can run on every commit.
 */
import { describe, it, expect, beforeAll } from '@jest/globals';
import { tmpdir } from 'os';
import path from 'path';
import fs from 'fs/promises';

import { resolveTheme, listThemes } from '../themes/index.js';
import { listArchetypes, renderArchetype } from '../archetypes/index.js';
import { listBackgrounds, applyBackground } from '../backgrounds/index.js';
import { PALETTES, resolvePalette, CHART_SERIES } from '../palettes.js';
import { resolveSpacing } from '../grid.js';
import { resolveShape } from '../shapes.js';
import { buildDeckContext, defineMasters, usesChrome } from '../master.js';
import { buildThemedPres, isThemedSpec, listSystem } from '../index.js';
import { readPres } from '../edit/read.js';

/* ─── slide spy — captures every pptxgenjs call without writing ──── */
function makeSlideSpy() {
  const calls = [];
  const slide = {
    _calls: calls,
    background: null,
    addText:   (...args) => { calls.push(['addText', args]); },
    addShape:  (...args) => { calls.push(['addShape', args]); },
    addImage:  (...args) => { calls.push(['addImage', args]); },
    addTable:  (...args) => { calls.push(['addTable', args]); },
    addChart:  (...args) => { calls.push(['addChart', args]); },
    addNotes:  (...args) => { calls.push(['addNotes', args]); },
  };
  return slide;
}

/* ─── minimal pptx stub for chart-dependent archetypes ───────────── */
const fakePptx = {
  ChartType: { bar: 'bar', doughnut: 'doughnut', pie: 'pie', line: 'line', area: 'area' },
};

/* ─── one example slide spec per archetype ───────────────────────── */
const SLIDE_FIXTURES = {
  hero:            { title: 'Hello', subtitle: 'World', eyebrow: 'Q4', author: 'Loxia', date: '2026-05-29' },
  agenda:          { title: 'Agenda', items: ['One', 'Two', 'Three'] },
  sectionHeader:   { number: '01', title: 'Section A', subtitle: 'Where we begin' },
  headlineMessage: { eyebrow: 'Tagline', message: 'A single sentence carries the whole slide.', footnote: 'Source: us' },
  productHero:     { eyebrow: 'New', title: 'Product Name', subtitle: 'A short tagline.', imagePosition: 'bottom' },
  statCallout:     { eyebrow: 'KPI', stat: '47%', label: 'YoY', footnote: 'Source' },
  bigStat:         { eyebrow: 'Headline', stat: '143', unit: '%', label: 'Net retention', delta: { value: '+8pp', direction: 'up' },
                     tags: [{ label: 'tag1', kind: 'highlight' }], footnote: 'Source' },
  statTrio:        { title: 'KPIs', stats: [
                       { label: 'ARR',  value: '$8.3', unit: 'M', delta: '+47% YoY', deltaDirection: 'up' },
                       { label: 'NRR',  value: '143',  unit: '%', delta: '+8pp',     deltaDirection: 'up' },
                       { label: 'Burn', value: '14',   unit: 'mo', delta: '-3mo',    deltaDirection: 'down' },
                     ] },
  comparisonGrid:  { title: 'A vs B', cells: [
                       { eyebrow: 'A', headline: 'Old', body: '...' },
                       { eyebrow: 'B', headline: 'New', body: '...' },
                     ] },
  bentoGrid:       { title: 'Inside', tiles: [
                       { colStart: 1, colSpan: 8, rowStart: 1, rowSpan: 2, kind: 'stat', value: '143', unit: '%', label: 'NRR' },
                       { colStart: 9, colSpan: 4, rowStart: 1, rowSpan: 1, kind: 'stat', value: '$8.3', unit: 'M', label: 'ARR' },
                       { colStart: 9, colSpan: 4, rowStart: 2, rowSpan: 1, kind: 'stat', value: '14', unit: 'mo', label: 'Burn' },
                       { colStart: 1, colSpan: 12, rowStart: 3, rowSpan: 1, kind: 'feature', headline: 'A thing happened', body: '...' },
                     ] },
  bullets:         { title: 'Wins', items: ['A', 'B', 'C'] },
  twoColText:      { title: 'Two cols', left: { headline: 'L', body: '...' }, right: { headline: 'R', body: '...' } },
  twoColChart:     { title: 'Chart', leftText: { headline: 'L', body: '...' },
                     chart: { type: 'donut', data: [{ label: 'A', value: 60 }, { label: 'B', value: 40 }] } },
  quote:           { quote: 'A sentence', attribution: 'Someone' },
  imageFullBleed:  { title: 'Title', eyebrow: 'Eyebrow' /* no image — fallback panel */ },
  table:           { title: 'Data', headers: ['A', 'B'], rows: [['1', '2'], ['3', '4']] },
  closing:         { eyebrow: 'Thanks', title: 'Bye', contact: [{ label: 'Email', value: 'x@y' }] },
  freeform:        { title: 'Freeform', items: [{ type: 'text', text: 'hi', x: 0, y: 0, w: 1, h: 0.1 }] },
};

const THEME_IDS = ['editorial-warm', 'tech-bold', 'data-heavy', 'mono-keynote'];

/* ─── tests ──────────────────────────────────────────────────────── */
describe('pres — every archetype × every theme renders without throwing', () => {
  const archetypeIds = listArchetypes();

  it('every archetype has a fixture in SLIDE_FIXTURES (catch missing test coverage)', () => {
    for (const id of archetypeIds) {
      expect(SLIDE_FIXTURES[id]).toBeDefined();
    }
  });

  for (const themeId of THEME_IDS) {
    describe(`theme=${themeId}`, () => {
      const resolved = resolveTheme({ theme: themeId });
      const ctx = {
        slideIndex: 0, totalSlides: 1,
        sectionName: 'TEST', sectionIdx: 0, sectionTotal: 1,
        brand: 'TestBrand', isSectionStart: false,
      };

      for (const archetypeId of archetypeIds) {
        it(`renders ${archetypeId}`, () => {
          const slide = makeSlideSpy();
          expect(() => {
            renderArchetype(slide, resolved, { archetype: archetypeId, ...SLIDE_FIXTURES[archetypeId] }, fakePptx, ctx);
          }).not.toThrow();
          // Smoke: the archetype must add SOMETHING to the slide.
          expect(slide._calls.length).toBeGreaterThan(0);
        });
      }
    });
  }
});

describe('pres/themes — resolver always returns a complete shape', () => {
  it('returns valid resolved object for an empty spec', () => {
    const r = resolveTheme({});
    expect(r.theme.id).toBeTruthy();
    expect(r.palette.bg).toMatch(/^[0-9A-F]{6}$/i);
    expect(r.spacing.safe).toMatchObject({ x: expect.any(Number), y: expect.any(Number), w: expect.any(Number), h: expect.any(Number) });
    expect(r.shape.cornerRadius).toBeDefined();
    expect(typeof r.bg).toBe('string');
  });

  it('falls back to editorial-warm for unknown theme id', () => {
    const r = resolveTheme({ theme: 'this-is-not-a-real-theme' });
    expect(r.theme.id).toBe('editorial-warm');
  });

  it('falls back to family default for unknown palette override', () => {
    const r = resolveTheme({ theme: 'tech-bold', palette: 'unknown-palette' });
    expect(r.palette.name).toBe('electric-night'); // tech-bold's family default
  });

  it('listThemes returns the 4 registered themes', () => {
    const names = listThemes().map(t => t.id).sort();
    expect(names).toEqual(['data-heavy', 'editorial-warm', 'mono-keynote', 'tech-bold']);
  });

  it('mono-keynote resolves to the mono-light palette by default', () => {
    const r = resolveTheme({ theme: 'mono-keynote' });
    expect(r.theme.family).toBe('mono');
    expect(r.palette.name).toBe('mono-light');
    expect(r.palette.primary).toBe('0070F3'); // Geist blue
    expect(r.bg).toBe('solid'); // mono is restrained
  });

  it('mono-keynote with mono-dark palette inverts surfaces correctly', () => {
    const r = resolveTheme({ theme: 'mono-keynote', palette: 'mono-dark' });
    expect(r.palette.bg).toBe('000000');
    expect(r.palette.text).toBe('FAFAFA');
  });
});

describe('pres/palettes — every palette has all 8 semantic roles', () => {
  it.each(Object.keys(PALETTES))('palette "%s" has every role', (name) => {
    const p = PALETTES[name];
    for (const role of ['bg', 'surface', 'text', 'subtle', 'primary', 'accent', 'ink', 'highlight']) {
      expect(p[role]).toMatch(/^[0-9A-F]{6}$/i);
    }
  });

  it.each(Object.keys(PALETTES))('palette "%s" has a CHART_SERIES entry', (name) => {
    expect(Array.isArray(CHART_SERIES[name])).toBe(true);
    expect(CHART_SERIES[name].length).toBeGreaterThanOrEqual(3);
  });

  it('resolvePalette always returns a usable palette object', () => {
    const r = resolvePalette('nonsense-name-xyz', 'editorial');
    expect(r.name).toBe('cream-charcoal');
    expect(r.bg).toMatch(/^[0-9A-F]{6}$/);
  });
});

describe('pres/grid + shapes — token resolvers', () => {
  it('resolveSpacing returns identical shape for unknown preset (defaults to cozy)', () => {
    const a = resolveSpacing('nonsense');
    const b = resolveSpacing('cozy');
    expect(Object.keys(a).sort()).toEqual(Object.keys(b).sort());
    expect(a.pageMargin).toBe(b.pageMargin);
  });

  it('resolveShape returns identical shape for unknown preset (defaults to soft)', () => {
    const a = resolveShape('nonsense');
    const b = resolveShape('soft');
    expect(a.cornerRadius).toBe(b.cornerRadius);
  });

  it('resolveSpacing.col() produces non-overlapping frames within the safe area', () => {
    const s = resolveSpacing('cozy');
    const left  = s.col(1, 6);
    const right = s.col(7, 6);
    expect(left.x + left.w).toBeLessThanOrEqual(right.x);
    expect(right.x + right.w).toBeLessThanOrEqual(s.safe.x + s.safe.w + 0.001);
  });
});

describe('pres/master — deck context tracks sections across slide list', () => {
  it('assigns each non-section slide to the most recent sectionHeader', () => {
    const { context } = buildDeckContext({
      title: 'Deck',
      slides: [
        { archetype: 'hero' },
        { archetype: 'sectionHeader', title: 'A' },
        { archetype: 'bullets' },
        { archetype: 'bullets' },
        { archetype: 'sectionHeader', title: 'B' },
        { archetype: 'closing' },
      ],
    });
    expect(context.map(c => c.sectionName)).toEqual([
      null,    // hero before any section
      'A',     // section A starts
      'A', 'A', // inside section A
      'B',     // section B starts
      'B',     // closing inside section B
    ]);
    expect(context.map(c => c.sectionIdx)).toEqual([0, 0, 0, 0, 1, 1]);
    expect(context[0].totalSlides).toBe(6);
    expect(context[0].sectionTotal).toBe(2);
  });

  it('isSectionStart is true ONLY on sectionHeader slides', () => {
    const { context } = buildDeckContext({
      slides: [
        { archetype: 'hero' },
        { archetype: 'sectionHeader' },
        { archetype: 'bullets' },
      ],
    });
    expect(context.map(c => c.isSectionStart)).toEqual([false, true, false]);
  });

  it('usesChrome returns false for bare archetypes', () => {
    expect(usesChrome('hero')).toBe(false);
    expect(usesChrome('sectionHeader')).toBe(false);
    expect(usesChrome('closing')).toBe(false);
    expect(usesChrome('imageFullBleed')).toBe(false);
    expect(usesChrome('headlineMessage')).toBe(false);
    expect(usesChrome('productHero')).toBe(false);
    // Default = chrome for everything else.
    expect(usesChrome('bullets')).toBe(true);
    expect(usesChrome('bentoGrid')).toBe(true);
  });
});

describe('pres/components — glass-card primitive', () => {
  it('renders without throwing on every theme', async () => {
    const { glassCard } = await import('../components.js');
    for (const themeId of THEME_IDS) {
      const resolved = resolveTheme({ theme: themeId });
      const slide = makeSlideSpy();
      expect(() => glassCard(slide, resolved, { x: 0.5, y: 0.5, w: 4, h: 2 })).not.toThrow();
      expect(slide._calls.length).toBeGreaterThan(0);
    }
  });

  it('paints two shapes when tinted (base + tint wash)', async () => {
    const { glassCard } = await import('../components.js');
    const resolved = resolveTheme({ theme: 'tech-bold' });
    const slide = makeSlideSpy();
    glassCard(slide, resolved, { x: 0.5, y: 0.5, w: 4, h: 2, tint: 'primary' });
    const shapes = slide._calls.filter(c => c[0] === 'addShape');
    expect(shapes.length).toBe(2);
  });

  it('paints only the base shape when not tinted', async () => {
    const { glassCard } = await import('../components.js');
    const resolved = resolveTheme({ theme: 'tech-bold' });
    const slide = makeSlideSpy();
    glassCard(slide, resolved, { x: 0.5, y: 0.5, w: 4, h: 2 });
    const shapes = slide._calls.filter(c => c[0] === 'addShape');
    expect(shapes.length).toBe(1);
  });
});

describe('pres/backgrounds — dispatch is total', () => {
  it('every named style resolves to a renderer', () => {
    const styles = listBackgrounds();
    expect(styles).toEqual(expect.arrayContaining(['solid', 'gradient', 'mesh', 'noise', 'geometric', 'grid', 'void', 'orb']));
  });

  it('every background renders against every theme without throwing', () => {
    const styles = listBackgrounds();
    for (const themeId of THEME_IDS) {
      const resolved = resolveTheme({ theme: themeId });
      for (const style of styles) {
        const slide = makeSlideSpy();
        expect(() => applyBackground(slide, resolved, style)).not.toThrow();
      }
    }
  });

  it('unknown background style falls back to solid (no throw)', () => {
    const resolved = resolveTheme({ theme: 'tech-bold' });
    const slide = makeSlideSpy();
    expect(() => applyBackground(slide, resolved, 'this-doesnt-exist')).not.toThrow();
    // Some shapes should have been added (or at minimum the bg colour set).
    expect(slide.background || slide._calls.length > 0).toBeTruthy();
  });
});

describe('pres — public API surface', () => {
  it('isThemedSpec discriminates between new and legacy shape', () => {
    expect(isThemedSpec({ theme: 'tech-bold' })).toBe(true);
    expect(isThemedSpec({ theme: { primaryColor: '#000' } })).toBe(false);
    expect(isThemedSpec({})).toBe(false);
    expect(isThemedSpec(null)).toBe(false);
  });

  it('listSystem returns themes / archetypes / backgrounds discovery', () => {
    const sys = listSystem();
    expect(Array.isArray(sys.themes)).toBe(true);
    expect(Array.isArray(sys.archetypes)).toBe(true);
    expect(Array.isArray(sys.backgrounds)).toBe(true);
    expect(sys.themes.length).toBe(4);
    expect(sys.archetypes.length).toBeGreaterThanOrEqual(17);
    expect(sys.backgrounds.length).toBeGreaterThanOrEqual(8);
  });
});

describe('pres — end-to-end: build + read round-trip', () => {
  let tmpFile;
  beforeAll(async () => {
    tmpFile = path.join(tmpdir(), `pres-test-${Date.now()}.pptx`);
    await buildThemedPres({
      title: 'Test deck',
      theme: 'tech-bold',
      slides: [
        { archetype: 'hero', title: 'Hello' },
        { archetype: 'sectionHeader', number: '01', title: 'Part one' },
        { archetype: 'bigStat', stat: '143', unit: '%', label: 'NRR' },
        { archetype: 'bullets', title: 'Wins', items: ['A', 'B', 'C'] },
        { archetype: 'closing', title: 'Bye' },
      ],
    }, tmpFile);
  });

  it('produces a real .pptx file', async () => {
    const stat = await fs.stat(tmpFile);
    expect(stat.size).toBeGreaterThan(10_000);
  });

  it('pres.read can parse the deck and count slides', async () => {
    const outline = await readPres(tmpFile);
    expect(outline.slideCount).toBe(5);
  });

  it('round-trip preserves the deck — tokens scan finds zero unresolved tokens', async () => {
    const outline = await readPres(tmpFile);
    // No tokens were authored — the result should be an empty array.
    expect(outline.tokens).toEqual([]);
  });
});
