/**
 * ili9341-emulation.test.ts
 *
 * End-to-end test for ILI9341 TFT display emulation:
 *
 *  1. Compile ili9341-test-sketch.ino with arduino-cli (arduino:avr:nano)
 *  2. Load the resulting .hex into AVRSimulator
 *  3. Intercept the SPI bus with a VirtualILI9341
 *  4. Run ~5 million simulated cycles
 *  5. Assert the display received pixel data (RAMWR command)
 *  6. Verify colored regions (red fill, white rect, blue circle)
 *
 * Requirements:
 *  - arduino-cli in PATH
 *  - "arduino:avr" core installed
 *  - "Adafruit ILI9341" and "Adafruit GFX Library" installed
 */

import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { spawnSync } from 'child_process';
import {
  mkdtempSync, writeFileSync, readFileSync, existsSync,
  rmSync, mkdirSync, readdirSync,
} from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';

import { AVRSimulator } from '../simulation/AVRSimulator';
import { PinManager } from '../simulation/PinManager';

// ─── ImageData polyfill ───────────────────────────────────────────────────────

if (typeof globalThis.ImageData === 'undefined') {
  class ImageDataPoly {
    readonly width: number;
    readonly height: number;
    readonly data: Uint8ClampedArray;
    constructor(w: number, h: number) {
      this.width  = w;
      this.height = h;
      this.data   = new Uint8ClampedArray(w * h * 4);
    }
  }
  (globalThis as any).ImageData = ImageDataPoly;
}

// ─── RAF stub ────────────────────────────────────────────────────────────────

vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1);
vi.stubGlobal('cancelAnimationFrame', vi.fn());

// ─── Paths ───────────────────────────────────────────────────────────────────

const SKETCH_DIR = resolve(
  __dirname,
  '../../../example_zip/extracted/ili9341-test-sketch',
);
const SKETCH_INO = join(SKETCH_DIR, 'ili9341-test-sketch.ino');

// ─── arduino-cli availability ─────────────────────────────────────────────────

const ARDUINO_CLI_AVAILABLE = (() => {
  const r = spawnSync('arduino-cli', ['version'], { encoding: 'utf-8' });
  return r.error == null && r.status === 0;
})();

// ─── Hex cache ───────────────────────────────────────────────────────────────

const HEX_CACHE = join(tmpdir(), 'velxio-ili9341-nano.hex');

function compileSketch(): string {
  if (existsSync(HEX_CACHE)) {
    console.log('[compile] Using cached hex:', HEX_CACHE);
    return readFileSync(HEX_CACHE, 'utf-8');
  }

  console.log('[compile] Compiling ili9341-test-sketch.ino for Arduino Nano…');

  const workDir    = mkdtempSync(join(tmpdir(), 'velxio-ili9341-'));
  const sketchDir  = join(workDir, 'ili9341-test-sketch');
  mkdirSync(sketchDir);
  writeFileSync(join(sketchDir, 'ili9341-test-sketch.ino'), readFileSync(SKETCH_INO, 'utf-8'));

  const buildDir = join(workDir, 'build');
  mkdirSync(buildDir);

  const result = spawnSync(
    'arduino-cli',
    [
      'compile',
      '--fqbn', 'arduino:avr:nano:cpu=atmega328old',
      '--build-path', buildDir,
      sketchDir,
    ],
    { encoding: 'utf-8', timeout: 120_000 },
  );

  if (result.error) {
    throw new Error(`arduino-cli not available: ${result.error.message}`);
  }
  if (result.status !== 0) {
    console.error('[compile] stdout:', result.stdout);
    console.error('[compile] stderr:', result.stderr);
    throw new Error(`arduino-cli failed (exit ${result.status}): ${result.stderr}`);
  }

  // Find .hex (prefer non-bootloader)
  let hexPath: string | null = null;
  for (const candidate of ['ili9341-test-sketch.ino.hex', 'sketch.ino.hex']) {
    const p = join(buildDir, candidate);
    if (existsSync(p)) { hexPath = p; break; }
  }
  if (!hexPath) {
    const files = readdirSync(buildDir, { recursive: true }) as string[];
    const found = files.find(
      (f) => typeof f === 'string' && f.endsWith('.hex') && !f.includes('bootloader'),
    );
    if (!found) throw new Error('No .hex found in build output');
    hexPath = join(buildDir, found);
  }

  const hex = readFileSync(hexPath, 'utf-8');
  writeFileSync(HEX_CACHE, hex);
  rmSync(workDir, { recursive: true });
  console.log('[compile] Done. Hex size:', hex.length, 'chars');
  return hex;
}

// ─── VirtualILI9341 SPI monitor ──────────────────────────────────────────────

/**
 * Intercepts the AVRSimulator SPI bus and decodes ILI9341 commands/data.
 *
 * ILI9341 protocol (hardware SPI):
 *  - DC pin LOW  → command byte
 *  - DC pin HIGH → data byte
 *  - Commands: 0x2A CASET, 0x2B PASET, 0x2C RAMWR, 0x01 SWRESET, etc.
 *  - Pixel data: RGB-565 (2 bytes per pixel) streamed after RAMWR
 */
class VirtualILI9341 {
  static readonly WIDTH  = 240;
  static readonly HEIGHT = 320;

  // Raw framebuffer: RGB-565 per pixel (0 = black / unwritten)
  readonly framebuffer = new Uint16Array(VirtualILI9341.WIDTH * VirtualILI9341.HEIGHT);

  // Statistics
  commandCount  = 0;
  ramwrCount    = 0;   // number of RAMWR (0x2C) commands received
  pixelCount    = 0;   // total pixels written

  // DC pin state (must be injected from AVRSimulator PinManager)
  dcHigh = false;

  // ILI9341 address window
  private colStart = 0; private colEnd = VirtualILI9341.WIDTH  - 1;
  private rowStart = 0; private rowEnd = VirtualILI9341.HEIGHT - 1;
  private curX     = 0; private curY   = 0;

  // Command state machine
  private currentCmd    = -1;
  private dataBytes: number[] = [];
  private inRamWrite    = false;
  private pixelHiByte   = 0;
  private pixelByteIdx  = 0;

  /**
   * Process one SPI byte.  Call with the byte value BEFORE calling
   * spi.completeTransfer() so we see it first.
   */
  processByte(value: number): void {
    if (!this.dcHigh) {
      // Command byte
      this.commandCount++;
      this.currentCmd   = value;
      this.dataBytes    = [];
      this.inRamWrite   = (value === 0x2C);
      this.pixelByteIdx = 0;
      if (value === 0x2C) this.ramwrCount++;
      if (value === 0x01) {  // SWRESET
        this.framebuffer.fill(0);
        this.pixelCount = 0;
      }
    } else {
      // Data byte
      if (this.inRamWrite) {
        if (this.pixelByteIdx === 0) {
          this.pixelHiByte  = value;
          this.pixelByteIdx = 1;
        } else {
          this.writePixel(this.pixelHiByte, value);
          this.pixelByteIdx = 0;
        }
      } else {
        this.dataBytes.push(value);
        this.applyCmd();
      }
    }
  }

  private applyCmd(): void {
    const d = this.dataBytes;
    switch (this.currentCmd) {
      case 0x2A: // CASET
        if (d.length === 2) this.colStart = (d[0] << 8) | d[1];
        if (d.length === 4) { this.colEnd = (d[2] << 8) | d[3]; this.curX = this.colStart; }
        break;
      case 0x2B: // PASET
        if (d.length === 2) this.rowStart = (d[0] << 8) | d[1];
        if (d.length === 4) { this.rowEnd = (d[2] << 8) | d[3]; this.curY = this.rowStart; }
        break;
    }
  }

  private writePixel(hi: number, lo: number): void {
    if (this.curX > this.colEnd || this.curY > this.rowEnd ||
        this.curX >= VirtualILI9341.WIDTH || this.curY >= VirtualILI9341.HEIGHT) return;

    const rgb565 = (hi << 8) | lo;
    this.framebuffer[this.curY * VirtualILI9341.WIDTH + this.curX] = rgb565;
    this.pixelCount++;

    this.curX++;
    if (this.curX > this.colEnd) {
      this.curX = this.colStart;
      this.curY++;
    }
  }

  /** Convert RGB-565 pixel to R,G,B channels */
  static rgb565ToRGB(p: number): [number, number, number] {
    return [
      ((p >> 11) & 0x1F) * 8,
      ((p >>  5) & 0x3F) * 4,
      ( p        & 0x1F) * 8,
    ];
  }

  /** True if any pixel in a region matches a specific colour (within tolerance) */
  regionHasColor(
    x0: number, y0: number, x1: number, y1: number,
    targetRGB565: number, tolerance = 20,
  ): boolean {
    const [tr, tg, tb] = VirtualILI9341.rgb565ToRGB(targetRGB565);
    for (let y = y0; y <= y1; y++) {
      for (let x = x0; x <= x1; x++) {
        const p = this.framebuffer[y * VirtualILI9341.WIDTH + x];
        if (p === 0) continue;
        const [r, g, b] = VirtualILI9341.rgb565ToRGB(p);
        if (
          Math.abs(r - tr) <= tolerance &&
          Math.abs(g - tg) <= tolerance &&
          Math.abs(b - tb) <= tolerance
        ) return true;
      }
    }
    return false;
  }

  /**
   * Count pixels whose colour is within `tolerance` of targetRGB565.
   */
  countColor(targetRGB565: number, tolerance = 20): number {
    const [tr, tg, tb] = VirtualILI9341.rgb565ToRGB(targetRGB565);
    let n = 0;
    for (const p of this.framebuffer) {
      if (p === 0) continue;
      const [r, g, b] = VirtualILI9341.rgb565ToRGB(p);
      if (
        Math.abs(r - tr) <= tolerance &&
        Math.abs(g - tg) <= tolerance &&
        Math.abs(b - tb) <= tolerance
      ) n++;
    }
    return n;
  }

  /** ILI9341_RED   (RGB-565: 0xF800) */
  static readonly COLOR_RED    = 0xF800;
  /** ILI9341_WHITE (RGB-565: 0xFFFF) */
  static readonly COLOR_WHITE  = 0xFFFF;
  /** ILI9341_BLUE  (RGB-565: 0x001F) */
  static readonly COLOR_BLUE   = 0x001F;
  /** ILI9341_BLACK (RGB-565: 0x0000) */
  static readonly COLOR_BLACK  = 0x0000;
  /** ILI9341_YELLOW (RGB-565: 0xFFE0) */
  static readonly COLOR_YELLOW = 0xFFE0;

  /** Summary string for console output */
  summary(): string {
    return (
      `ILI9341 stats: commands=${this.commandCount}, ` +
      `RAMWR=${this.ramwrCount}, pixelsWritten=${this.pixelCount}`
    );
  }
}

// ─── Run N cycles ─────────────────────────────────────────────────────────────

function runCycles(sim: AVRSimulator, cycles: number): void {
  for (let i = 0; i < cycles; i++) sim.step();
}

// ─── Tests ───────────────────────────────────────────────────────────────────

describe.skipIf(!ARDUINO_CLI_AVAILABLE)('ILI9341 emulation — full end-to-end', () => {
  let hexContent: string;
  let sim: AVRSimulator;
  let display: VirtualILI9341;

  beforeAll(() => {
    hexContent = compileSketch();
  });

  afterAll(() => {
    try { sim?.stop(); } catch { /* ignore */ }
    vi.unstubAllGlobals();
  });

  it('🔧 compiles ili9341-test-sketch.ino successfully', () => {
    expect(hexContent).toBeTruthy();
    expect(hexContent).toContain(':');
    console.log('[hex] First line:', hexContent.split('\n')[0]);
  });

  it('🖥️  boots, initialises ILI9341, and fills screen within 15M cycles', () => {
    const pm = new PinManager();
    sim = new AVRSimulator(pm);
    sim.loadHex(hexContent);

    // ── Attach VirtualILI9341 to the SPI bus ──────────────────────────────
    display = new VirtualILI9341();

    // DC pin: Arduino pin 9 → PORTB bit 1 (D9 = PB1)
    // We track DC state by watching pin 9 in the pinManager.
    // PinManager.onPinChange gives us the logical state (true = HIGH).
    const DC_ARDUINO_PIN = 9;
    pm.onPinChange(DC_ARDUINO_PIN, (_pin: number, state: boolean) => {
      display.dcHigh = state;
    });

    // Intercept SPI: wrap the existing onByte
    sim.spi!.onByte = (value: number) => {
      display.processByte(value);
      sim.spi!.completeTransfer(0xFF);
    };

    // 15M cycles = ~937ms simulated @16MHz.
    // Breakdown:
    //   ~3M  – Arduino startup + Serial.begin + tft.begin() init commands (~20+ cmd)
    //   ~2M  – fillScreen(RED) = 76800px × 2bytes × ~4CPU/byte + overhead
    //   ~6M  – fillRect + fillCircle + fillTriangle
    //   ~4M  – margin
    runCycles(sim, 15_000_000);

    console.log(`[init] ${display.summary()}`);
    console.log(`[init] Pixels written: ${display.pixelCount}`);

    // The Adafruit_ILI9341::begin() sends ~20+ init commands
    expect(display.commandCount).toBeGreaterThan(10);
  });

  it('🎨 fillScreen(RED) was issued (pixels present after 15M cycles)', () => {
    // By the time we reach this test, 15M cycles have already run.
    // fillScreen alone writes 76800 pixels; we accept ≥10000 to be lenient.
    runCycles(sim, 0); // no-op; just reads current state

    console.log(`[fill] ${display.summary()}`);

    const redCount = display.countColor(VirtualILI9341.COLOR_RED, 30);
    console.log(`[fill] Red pixels: ${redCount} / ${VirtualILI9341.WIDTH * VirtualILI9341.HEIGHT}`);

    // At least the majority of the screen should be red
    expect(redCount).toBeGreaterThan(10_000);
  });

  it('⬜ draws white rectangle at (20,20,200,80)', () => {
    // White rect was drawn before circle — should already be in framebuffer
    const hasWhiteRect = display.regionHasColor(20, 20, 220, 100, VirtualILI9341.COLOR_WHITE, 10);
    console.log(`[rect] White rect region has white pixels: ${hasWhiteRect}`);
    expect(hasWhiteRect).toBe(true);
  });

  it('🔵 draws blue circle center (120,200) r=50', () => {
    // Check a small region within the circle bounds
    const hasBluePx = display.regionHasColor(90, 170, 150, 230, VirtualILI9341.COLOR_BLUE, 30);
    console.log(`[circle] Blue circle region has blue pixels: ${hasBluePx}`);
    expect(hasBluePx).toBe(true);
  });

  it('🖼️  RAMWR received multiple times (multi-shape drawing)', () => {
    console.log(`[ramwr] RAMWR count: ${display.ramwrCount}`);
    // fillScreen + fillRect + fillCircle + fillTriangle → at least 4 RAMWR
    expect(display.ramwrCount).toBeGreaterThan(3);
  });

  it('📊 total pixel writes cover most of the 240×320 screen', () => {
    const total = VirtualILI9341.WIDTH * VirtualILI9341.HEIGHT;
    const coverage = display.pixelCount / total;
    console.log(
      `[coverage] pixelCount=${display.pixelCount} / ${total} = ${(coverage * 100).toFixed(1)}%`
    );
    // fillScreen alone should cover 100%; we expect at least 50% to account
    // for cases where address windows overlap (pixels counted once per write)
    expect(display.pixelCount).toBeGreaterThan(total * 0.5);
  });
});
