/// <reference types="node" />
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
  loadFont,
  loadFontByFamily,
  textToPathData,
  getAvailableFonts,
  clearFontCache,
  isFontCached,
} from '../fontService';

const __filename_local = fileURLToPath(import.meta.url);
const __dirname_local = path.dirname(__filename_local);

/**
 * Load a real .ttf file from disk and return it as an ArrayBuffer.
 * This lets us mock fetch() with real font data so opentype.js
 * produces genuine glyph paths, proving the integration end-to-end.
 */
function readFontFile(filename: string): ArrayBuffer {
  const fontPath = path.resolve(__dirname_local, '../../../public/fonts', filename);
  const buffer = fs.readFileSync(fontPath);
  return buffer.buffer.slice(
    buffer.byteOffset,
    buffer.byteOffset + buffer.byteLength,
  );
}

/** Create a mock fetch Response from an ArrayBuffer. */
function mockFetchResponse(arrayBuffer: ArrayBuffer): Response {
  return {
    ok: true,
    status: 200,
    arrayBuffer: () => Promise.resolve(arrayBuffer),
  } as unknown as Response;
}

/** Create a mock fetch Response for an error. */
function mockFetchError(status: number): Response {
  return {
    ok: false,
    status,
    arrayBuffer: () => Promise.reject(new Error('should not be called')),
  } as unknown as Response;
}

// Cache font buffers so we don't read from disk on every test
let latoBuffer: ArrayBuffer;
let robotoBuffer: ArrayBuffer;
let openSansBuffer: ArrayBuffer;

beforeEach(() => {
  // Read font files once (lazy — subsequent tests reuse the reference)
  if (!latoBuffer) latoBuffer = readFontFile('Lato-Regular.ttf');
  if (!robotoBuffer) robotoBuffer = readFontFile('Roboto-Regular.ttf');
  if (!openSansBuffer) openSansBuffer = readFontFile('OpenSans-Regular.ttf');
});

describe('fontService', () => {
  beforeEach(() => {
    clearFontCache();
    // Default mock: route any /fonts/*.ttf request to the correct buffer
    vi.stubGlobal(
      'fetch',
      vi.fn((url: string) => {
        if (url.includes('Lato'))
          return Promise.resolve(mockFetchResponse(latoBuffer));
        if (url.includes('Roboto'))
          return Promise.resolve(mockFetchResponse(robotoBuffer));
        if (url.includes('OpenSans'))
          return Promise.resolve(mockFetchResponse(openSansBuffer));
        return Promise.resolve(mockFetchError(404));
      }),
    );
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  // ── getAvailableFonts ──

  describe('getAvailableFonts', () => {
    it('returns at least 3 bundled fonts', () => {
      const fonts = getAvailableFonts();
      expect(fonts.length).toBeGreaterThanOrEqual(3);
    });

    it('each font has family and file properties', () => {
      for (const f of getAvailableFonts()) {
        expect(f.family).toBeTruthy();
        expect(f.file).toMatch(/\.ttf$/);
      }
    });

    it('includes Roboto, Open Sans, and Lato', () => {
      const families = getAvailableFonts().map((f) => f.family);
      expect(families).toContain('Roboto');
      expect(families).toContain('Open Sans');
      expect(families).toContain('Lato');
    });
  });

  // ── loadFont ──

  describe('loadFont', () => {
    it('fetches and parses a font by URL', async () => {
      const font = await loadFont('/fonts/Lato-Regular.ttf');
      expect(font).toBeDefined();
      expect(font.unitsPerEm).toBeGreaterThan(0);
    });

    it('caches the font after first load', async () => {
      await loadFont('/fonts/Lato-Regular.ttf');
      expect(isFontCached('/fonts/Lato-Regular.ttf')).toBe(true);
      // Second call should not trigger fetch again
      await loadFont('/fonts/Lato-Regular.ttf');
      expect(fetch).toHaveBeenCalledTimes(1);
    });

    it('deduplicates concurrent requests for the same URL', async () => {
      const p1 = loadFont('/fonts/Lato-Regular.ttf');
      const p2 = loadFont('/fonts/Lato-Regular.ttf');
      const [f1, f2] = await Promise.all([p1, p2]);
      expect(f1).toBe(f2);
      expect(fetch).toHaveBeenCalledTimes(1);
    });

    it('throws on fetch failure', async () => {
      vi.stubGlobal(
        'fetch',
        vi.fn(() => Promise.resolve(mockFetchError(500))),
      );
      await expect(loadFont('/fonts/bad.ttf')).rejects.toThrow(
        /Failed to fetch font/,
      );
    });

    it('clears cache via clearFontCache', async () => {
      await loadFont('/fonts/Lato-Regular.ttf');
      expect(isFontCached('/fonts/Lato-Regular.ttf')).toBe(true);
      clearFontCache();
      expect(isFontCached('/fonts/Lato-Regular.ttf')).toBe(false);
    });
  });

  // ── loadFontByFamily ──

  describe('loadFontByFamily', () => {
    it('loads Roboto by family name', async () => {
      const font = await loadFontByFamily('Roboto');
      expect(font).toBeDefined();
      expect(font.unitsPerEm).toBe(2048);
    });

    it('is case-insensitive', async () => {
      const font = await loadFontByFamily('roboto');
      expect(font).toBeDefined();
    });

    it('falls back to first bundled font for unknown families', async () => {
      const font = await loadFontByFamily('UnknownFont');
      expect(font).toBeDefined();
      // Should have loaded the first bundled font (Roboto)
      expect(fetch).toHaveBeenCalledWith(
        expect.stringContaining('Roboto'),
      );
    });
  });

  // ── textToPathData ──

  describe('textToPathData', () => {
    it('produces non-empty path data for simple text', async () => {
      const result = await textToPathData('Hello', 'Lato', 24);
      expect(result.pathData).toBeTruthy();
      expect(result.pathData.length).toBeGreaterThan(10);
    });

    it('path data starts with M (moveto) command', async () => {
      const result = await textToPathData('A', 'Lato', 24);
      expect(result.pathData).toMatch(/^M/);
    });

    it('path data contains Z (closepath) commands', async () => {
      const result = await textToPathData('O', 'Lato', 24);
      expect(result.pathData).toContain('Z');
    });

    it('returns positive width and height', async () => {
      const result = await textToPathData('Hello', 'Lato', 24);
      expect(result.width).toBeGreaterThan(0);
      expect(result.height).toBeGreaterThan(0);
    });

    it('width scales with text length', async () => {
      const short = await textToPathData('Hi', 'Lato', 24);
      const long = await textToPathData('Hello World', 'Lato', 24);
      expect(long.width).toBeGreaterThan(short.width);
    });

    it('height scales with font size', async () => {
      const small = await textToPathData('A', 'Lato', 12);
      const large = await textToPathData('A', 'Lato', 48);
      expect(large.height).toBeGreaterThan(small.height);
      // Height should scale roughly linearly with fontSize
      const ratio = large.height / small.height;
      expect(ratio).toBeCloseTo(4, 0); // 48/12 = 4
    });

    it('letter spacing increases total width', async () => {
      const normal = await textToPathData('Hello', 'Lato', 24, 0);
      const spaced = await textToPathData('Hello', 'Lato', 24, 5);
      // 4 inter-character gaps × 5px = 20px extra
      expect(spaced.width).toBeGreaterThan(normal.width);
      const diff = spaced.width - normal.width;
      expect(diff).toBeCloseTo(20, 0);
    });

    it('negative letter spacing decreases total width', async () => {
      const normal = await textToPathData('Hello', 'Lato', 24, 0);
      const tight = await textToPathData('Hello', 'Lato', 24, -1);
      expect(tight.width).toBeLessThan(normal.width);
    });

    it('produces valid path data for all bundled fonts', async () => {
      for (const fontDesc of getAvailableFonts()) {
        const result = await textToPathData('Test', fontDesc.family, 24);
        expect(result.pathData).toBeTruthy();
        expect(result.width).toBeGreaterThan(0);
        expect(result.height).toBeGreaterThan(0);
      }
    });

    it('Y coordinates are positive (canvas coordinate system)', async () => {
      const result = await textToPathData('A', 'Lato', 48);
      // Extract all Y values from M and L commands
      const yValues = [...result.pathData.matchAll(/[ML][\d.-]+ ([\d.-]+)/g)].map(
        (m) => parseFloat(m[1]),
      );
      // All Y values should be >= 0 (top-left origin, Y-down)
      expect(yValues.length).toBeGreaterThan(0);
      for (const y of yValues) {
        expect(y).toBeGreaterThanOrEqual(0);
      }
    });

    it('handles empty string gracefully', async () => {
      const result = await textToPathData('', 'Lato', 24);
      expect(result.pathData).toBe('');
      expect(result.width).toBe(0);
      expect(result.height).toBeGreaterThan(0); // height is font-metric based
    });

    it('handles space-only string', async () => {
      const result = await textToPathData('   ', 'Lato', 24);
      // Spaces produce no visible glyphs but advance the cursor
      expect(result.width).toBeGreaterThan(0);
    });

    it('returns consistent results for repeated calls (caching)', async () => {
      const r1 = await textToPathData('Same', 'Lato', 24);
      const r2 = await textToPathData('Same', 'Lato', 24);
      expect(r1.pathData).toBe(r2.pathData);
      expect(r1.width).toBe(r2.width);
      expect(r1.height).toBe(r2.height);
    });
  });
});
