import { describe, expect, it } from 'vitest';
import { cssVariables, heightClassFor, planLayout, sizeClassFor } from './viewport';
import type { ViewportMeasurement } from './viewport';

/**
 * Real devices, in CSS pixels. These are the measurements the layout has to
 * survive, so they are the test cases — the point of `planLayout` being pure is
 * that "does BikeControl fit on a Pixel 7" is a question answerable in CI, on a
 * machine with no phone attached (G-4).
 */
const DEVICES = {
  pixel7Portrait: { width: 412, height: 915, dpr: 2.625, touch: true },
  pixel7Landscape: { width: 915, height: 412, dpr: 2.625, touch: true },
  smallPhone: { width: 360, height: 640, dpr: 3, touch: true },
  tablet: { width: 1280, height: 800, dpr: 2, touch: true },
  desktop: { width: 1440, height: 900, dpr: 1, touch: false },
  desktop4k: { width: 2560, height: 1440, dpr: 2, touch: false },
  shortWindow: { width: 1280, height: 420, dpr: 1, touch: false },
} satisfies Record<string, ViewportMeasurement>;

/**
 * The legibility contract from FR-9.5, restated as numbers. Nothing the planner
 * does may put a readout under these, on any device.
 */
const FLOOR = { hero: 40, big: 28, mid: 18, small: 14, label: 10, sub: 11 };

describe('sizeClassFor', () => {
  it('puts every phone width in the compact band', () => {
    expect(sizeClassFor(360)).toBe('compact');
    expect(sizeClassFor(412)).toBe('compact');
    expect(sizeClassFor(599)).toBe('compact');
  });

  it('bands the rest on the Material boundaries', () => {
    expect(sizeClassFor(600)).toBe('medium');
    expect(sizeClassFor(899)).toBe('medium');
    expect(sizeClassFor(900)).toBe('expanded');
    expect(sizeClassFor(1279)).toBe('expanded');
    expect(sizeClassFor(1280)).toBe('large');
  });
});

describe('heightClassFor', () => {
  it('bands on the heights that change what fits', () => {
    expect(heightClassFor(412)).toBe('short');
    expect(heightClassFor(640)).toBe('medium');
    expect(heightClassFor(915)).toBe('tall');
  });
});

describe('planLayout', () => {
  it('never puts a readout below its legibility floor', () => {
    for (const [name, m] of Object.entries(DEVICES)) {
      const { type } = planLayout(m);
      for (const key of Object.keys(FLOOR) as (keyof typeof FLOOR)[]) {
        expect(type[key], `${name}.${key}`).toBeGreaterThanOrEqual(FLOOR[key]);
      }
    }
  });

  it('keeps the primary readouts inside the viewport width', () => {
    // A readout is roughly `hero` wide per digit-pair plus its gutter. The
    // check that matters is the coarse one: columns × floor width must fit,
    // or the row wraps and the hero number is clipped.
    for (const [name, m] of Object.entries(DEVICES)) {
      const plan = planLayout(m);
      const needed =
        plan.columns.primary * plan.type.hero + (plan.columns.primary - 1) * plan.gapPx + 2 * plan.edgePx;
      expect(needed, `${name} primary row`).toBeLessThanOrEqual(m.width);
    }
  });

  it('drops the sparklines on a phone rather than drawing a smear', () => {
    expect(planLayout(DEVICES.pixel7Portrait).show.streamCharts).toBe(false);
    expect(planLayout(DEVICES.pixel7Landscape).show.streamCharts).toBe(false);
    expect(planLayout(DEVICES.desktop).show.streamCharts).toBe(true);
  });

  it('keeps the route chart on a phone in portrait — it is the screen', () => {
    expect(planLayout(DEVICES.pixel7Portrait).show.routeChart).toBe(true);
  });

  it('gives up the route chart only when both axes are gone', () => {
    // A phone in landscape is 412 px tall: the route chart plus the numbers do
    // not both fit, and the numbers win.
    expect(planLayout({ width: 568, height: 320, dpr: 3, touch: true }).show.routeChart).toBe(false);
    // A short *wide* window still has room for it.
    expect(planLayout(DEVICES.shortWindow).show.routeChart).toBe(true);
  });

  it('hides keyboard hints when there is no keyboard', () => {
    expect(planLayout(DEVICES.pixel7Portrait).show.keyHints).toBe(false);
    expect(planLayout(DEVICES.desktop).show.keyHints).toBe(true);
  });

  it('meets the 48 px touch target minimum on touch devices', () => {
    expect(planLayout(DEVICES.pixel7Portrait).touchTargetPx).toBeGreaterThanOrEqual(48);
    expect(planLayout(DEVICES.tablet).touchTargetPx).toBeGreaterThanOrEqual(48);
  });

  it('narrows the primary grid as the screen narrows, monotonically', () => {
    const widths = [360, 412, 600, 900, 1280, 1920];
    const columns = widths.map((width) => planLayout({ width, height: 900, dpr: 2, touch: false }).columns.primary);
    for (let i = 1; i < columns.length; i++) {
      expect(columns[i], `${widths[i]}px`).toBeGreaterThanOrEqual(columns[i - 1]);
    }
  });

  it('reports orientation from the measurement, not from the platform', () => {
    expect(planLayout(DEVICES.pixel7Portrait).orientation).toBe('portrait');
    expect(planLayout(DEVICES.pixel7Landscape).orientation).toBe('landscape');
    // Square is portrait by the >= rule; the point is that it is defined.
    expect(planLayout({ width: 800, height: 800, dpr: 1, touch: false }).orientation).toBe('portrait');
  });

  it('survives a zero-sized viewport on the first frame', () => {
    const plan = planLayout({ width: 0, height: 0, dpr: 1, touch: false });
    expect(Number.isFinite(plan.type.hero)).toBe(true);
    expect(plan.type.hero).toBeGreaterThanOrEqual(FLOOR.hero);
    expect(plan.columns.primary).toBeGreaterThan(0);
  });

  it('does not grow type without bound on a very large display', () => {
    const huge = planLayout({ width: 7680, height: 4320, dpr: 1, touch: false });
    expect(huge.type.hero).toBeLessThanOrEqual(116);
  });
});

describe('cssVariables', () => {
  it('emits every value with a unit or as a bare count, never NaN', () => {
    const vars = cssVariables(planLayout(DEVICES.pixel7Portrait));
    for (const [name, value] of Object.entries(vars)) {
      expect(value, name).not.toContain('NaN');
      if (name.startsWith('--cols-')) {
        expect(Number(value), name).toBeGreaterThan(0);
      } else {
        expect(value, name).toMatch(/^\d+px$/);
      }
    }
  });
});
