import { describe, expect, it } from 'vitest';

import { ValidationError } from '../errors/index.js';
import {
  ageBand,
  computeAge,
  formatFullName,
  formatFullNameLastFirst,
  formatPhilippineMobile,
  isValidEmail,
  normalizeEmail,
  normalizePersonName,
  normalizePhilippineMobile,
  parseDateOnly,
  toDateOnlyString,
} from './index.js';
import { formatApplicationReference, formatStudentNumber, parseApplicationReference, seededRandom, suppressSmallCell } from '../util/index.js';

describe('Philippine mobile normalization', () => {
  it('maps every common local format to one canonical value', () => {
    const canonical = '+639171234567';
    for (const input of [
      '09171234567',
      '0917 123 4567',
      '0917-123-4567',
      '(0917) 123 4567',
      '+639171234567',
      '639171234567',
      '9171234567',
    ]) {
      expect(normalizePhilippineMobile(input), input).toBe(canonical);
    }
  });

  it('returns null for numbers that are not PH mobiles, instead of throwing', () => {
    for (const input of ['02-8123-4567', '+1 555 0100', 'not a number', '']) {
      expect(normalizePhilippineMobile(input), input).toBeNull();
    }
  });

  it('renders back to the local display form', () => {
    expect(formatPhilippineMobile('+639171234567')).toBe('0917 123 4567');
    expect(formatPhilippineMobile('+15550100')).toBe('+15550100');
  });
});

describe('email normalization', () => {
  it('trims and lowercases', () => {
    expect(normalizeEmail('  Juan.DelaCruz@Example.Local ')).toBe('juan.delacruz@example.local');
  });

  it('preserves sub-addressing, because two tags are two people', () => {
    expect(normalizeEmail('family+juan@example.local')).toBe('family+juan@example.local');
    expect(normalizeEmail('family+maria@example.local')).not.toBe(
      normalizeEmail('family+juan@example.local'),
    );
  });

  it('validates shape', () => {
    expect(isValidEmail('a@b.local')).toBe(true);
    expect(isValidEmail('a@b')).toBe(false);
    expect(isValidEmail('no-at-sign')).toBe(false);
  });
});

describe('person names', () => {
  it('collapses whitespace and title-cases', () => {
    expect(normalizePersonName('  juan   MIGUEL  ')).toBe('Juan Miguel');
  });

  it('keeps Filipino and Spanish particles lowercase', () => {
    expect(normalizePersonName('juan DELA cruz')).toBe('Juan dela Cruz');
    expect(normalizePersonName('maria DE los santos')).toBe('Maria de los Santos');
  });

  it('formats the official last-name-first form with a middle initial', () => {
    expect(
      formatFullNameLastFirst({
        lastName: 'Dela Cruz',
        firstName: 'Juan Miguel',
        middleName: 'Pascual',
        suffix: 'Jr.',
      }),
    ).toBe('Dela Cruz, Juan Miguel P., Jr.');
  });

  it('omits an absent middle name and suffix cleanly', () => {
    expect(formatFullNameLastFirst({ lastName: 'Reyes', firstName: 'Ana' })).toBe('Reyes, Ana');
    expect(formatFullName({ lastName: 'Reyes', firstName: 'Ana', middleName: null })).toBe(
      'Ana Reyes',
    );
  });
});

describe('date-only values', () => {
  it('anchors a calendar date at UTC midnight so it cannot shift a day', () => {
    const date = parseDateOnly('2004-03-12');
    expect(date.toISOString()).toBe('2004-03-12T00:00:00.000Z');
    expect(toDateOnlyString(date)).toBe('2004-03-12');
  });

  it('rejects malformed and impossible dates', () => {
    expect(() => parseDateOnly('12/03/2004')).toThrow(ValidationError);
    expect(() => parseDateOnly('2004-02-30')).toThrow(/not a real calendar date/);
  });
});

describe('age and demographic bands', () => {
  it('computes whole years elapsed', () => {
    const birth = parseDateOnly('2004-08-26');
    expect(computeAge(birth, parseDateOnly('2026-08-25'))).toBe(21);
    expect(computeAge(birth, parseDateOnly('2026-08-26'))).toBe(22);
  });

  it('buckets ages rather than reporting them exactly', () => {
    expect(ageBand(17)).toBe('Under 18');
    expect(ageBand(19)).toBe('18–20');
    expect(ageBand(23)).toBe('21–24');
    expect(ageBand(27)).toBe('25–29');
    expect(ageBand(35)).toBe('30–39');
    expect(ageBand(51)).toBe('40 and above');
  });
});

describe('reference numbers', () => {
  it('formats and round-trips an application reference', () => {
    const reference = formatApplicationReference(2026, 417);
    expect(reference).toBe('APP-2026-000417');
    expect(parseApplicationReference(reference)).toEqual({ year: 2026, sequence: 417 });
  });

  it('rejects a malformed reference', () => {
    expect(() => parseApplicationReference('APP-26-417')).toThrow(ValidationError);
  });

  it('formats a student number', () => {
    expect(formatStudentNumber(2026, 184)).toBe('2026-00184');
  });
});

describe('small-cell suppression (SPEC.md §24)', () => {
  it('hides counts below the configured threshold', () => {
    expect(suppressSmallCell(3, 5)).toBe('< 5');
    expect(suppressSmallCell(4, 5)).toBe('< 5');
  });

  it('reports counts at or above the threshold exactly', () => {
    expect(suppressSmallCell(5, 5)).toBe('5');
    expect(suppressSmallCell(120, 5)).toBe('120');
  });

  it('reports a true zero, which identifies nobody', () => {
    expect(suppressSmallCell(0, 5)).toBe('0');
  });
});

describe('deterministic seed randomness', () => {
  it('produces identical sequences for identical seeds', () => {
    const a = seededRandom('i-learned-2026');
    const b = seededRandom('i-learned-2026');
    const takeFive = (rng: ReturnType<typeof seededRandom>): number[] =>
      Array.from({ length: 5 }, () => rng.int(0, 1000));
    expect(takeFive(a)).toEqual(takeFive(b));
  });

  it('produces different sequences for different seeds', () => {
    expect(seededRandom('a').int(0, 1_000_000)).not.toBe(seededRandom('b').int(0, 1_000_000));
  });

  it('shuffles without losing or duplicating items', () => {
    const items = Array.from({ length: 20 }, (_, index) => index);
    const shuffled = seededRandom('shuffle').shuffle(items);
    expect(shuffled).toHaveLength(items.length);
    expect([...shuffled].sort((x, y) => x - y)).toEqual(items);
  });

  it('stays within the requested integer bounds', () => {
    const rng = seededRandom('bounds');
    for (let i = 0; i < 200; i += 1) {
      const value = rng.int(5, 10);
      expect(value).toBeGreaterThanOrEqual(5);
      expect(value).toBeLessThanOrEqual(10);
    }
  });
});
