import { describe, expect, it } from 'vitest';
import {
  BINARY_FRAME,
  decodeBinaryFrame,
  encodeBinaryFrame,
  parseClientMessage,
  type ClientMessage,
} from '../src/protocol.js';

// PRNG déterministe pour le fuzz (reproductible d'un run à l'autre)
function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const isU32 = (v: number): boolean => Number.isInteger(v) && v >= 0 && v <= 0xffffffff;
const isDim = (v: number): boolean => Number.isInteger(v) && v >= 2 && v <= 1000;

// Invariants que tout message accepté DOIT respecter, quel que soit l'input
function assertInvariants(msg: ClientMessage): void {
  switch (msg.type) {
    case 'hello':
      expect(typeof msg.protocol).toBe('number');
      break;
    case 'attach':
      expect(typeof msg.sessionId).toBe('string');
      expect(['interactive', 'observer']).toContain(msg.mode);
      expect(isDim(msg.cols)).toBe(true);
      expect(isDim(msg.rows)).toBe(true);
      break;
    case 'detach':
      expect(isU32(msg.channel)).toBe(true);
      break;
    case 'stdin':
      expect(isU32(msg.channel)).toBe(true);
      expect(typeof msg.data).toBe('string');
      expect(msg.data.length).toBeLessThanOrEqual(65536);
      break;
    case 'answer':
      expect(isU32(msg.channel)).toBe(true);
      expect(['select', 'confirm', 'deny']).toContain(msg.action);
      if (msg.action === 'select') {
        expect(typeof msg.optionN).toBe('number');
        expect(msg.optionN).toBeGreaterThanOrEqual(1);
        expect(msg.optionN).toBeLessThanOrEqual(99);
      }
      break;
    case 'resize':
      expect(isU32(msg.channel)).toBe(true);
      expect(isDim(msg.cols)).toBe(true);
      expect(isDim(msg.rows)).toBe(true);
      break;
    case 'ack':
      expect(isU32(msg.channel)).toBe(true);
      expect(typeof msg.bytes).toBe('number');
      expect(msg.bytes).toBeGreaterThanOrEqual(0);
      break;
    case 'sub':
      expect(msg.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups')).toBe(true);
      break;
    case 'ping':
      break;
  }
}

describe('parseClientMessage — cas valides', () => {
  it('hello', () => {
    expect(parseClientMessage('{"type":"hello","protocol":1}')).toEqual({ type: 'hello', protocol: 1 });
  });

  it('attach avec dimensions aux bornes', () => {
    const make = (cols: number, rows: number): string =>
      JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols, rows });
    expect(parseClientMessage(make(2, 2))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 2, rows: 2 });
    expect(parseClientMessage(make(1000, 1000))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 1000, rows: 1000 });
  });

  it('attach en mode observer', () => {
    expect(parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 })))
      .toEqual({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 });
  });

  it('detach avec channel aux bornes u32', () => {
    expect(parseClientMessage('{"type":"detach","channel":0}')).toEqual({ type: 'detach', channel: 0 });
    expect(parseClientMessage(`{"type":"detach","channel":${0xffffffff}}`)).toEqual({ type: 'detach', channel: 0xffffffff });
  });

  it('stdin avec data à la taille max', () => {
    const data = 'x'.repeat(65536);
    expect(parseClientMessage(JSON.stringify({ type: 'stdin', channel: 1, data }))).toEqual({ type: 'stdin', channel: 1, data });
  });

  it('resize', () => {
    expect(parseClientMessage('{"type":"resize","channel":3,"cols":120,"rows":32}'))
      .toEqual({ type: 'resize', channel: 3, cols: 120, rows: 32 });
  });

  it('answer select / confirm / deny', () => {
    expect(parseClientMessage('{"type":"answer","channel":2,"action":"select","optionN":3}'))
      .toEqual({ type: 'answer', channel: 2, action: 'select', optionN: 3 });
    expect(parseClientMessage('{"type":"answer","channel":2,"action":"confirm"}'))
      .toEqual({ type: 'answer', channel: 2, action: 'confirm' });
    expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny"}'))
      .toEqual({ type: 'answer', channel: 2, action: 'deny' });
    // optionN n'est porté que par 'select' (ignoré pour confirm/deny)
    expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny","optionN":3}'))
      .toEqual({ type: 'answer', channel: 2, action: 'deny' });
  });

  it('ack à zéro octet', () => {
    expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
  });

  it('sub avec topics sessions/worktrees/groups (et tableau vide accepté)', () => {
    expect(parseClientMessage('{"type":"sub","topics":["sessions"]}')).toEqual({ type: 'sub', topics: ['sessions'] });
    expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees"]}')).toEqual({ type: 'sub', topics: ['sessions', 'worktrees'] });
    expect(parseClientMessage('{"type":"sub","topics":["groups"]}')).toEqual({ type: 'sub', topics: ['groups'] });
    expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees","groups"]}')).toEqual({
      type: 'sub',
      topics: ['sessions', 'worktrees', 'groups'],
    });
    expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] });
  });

  it('ping (champs superflus ignorés)', () => {
    expect(parseClientMessage('{"type":"ping","extra":42}')).toEqual({ type: 'ping' });
  });
});

describe('parseClientMessage — cas malformés', () => {
  it('JSON invalide ou non-objet', () => {
    for (const raw of ['', 'not json', '{', '42', '"str"', 'null', 'true', '[]', '[1,2]']) {
      expect(parseClientMessage(raw)).toBeNull();
    }
  });

  it('type absent, non-string ou inconnu', () => {
    expect(parseClientMessage('{}')).toBeNull();
    expect(parseClientMessage('{"type":42}')).toBeNull();
    expect(parseClientMessage('{"type":null}')).toBeNull();
    expect(parseClientMessage('{"type":"unknown"}')).toBeNull();
  });

  it('hello sans protocol ou protocol non numérique', () => {
    expect(parseClientMessage('{"type":"hello"}')).toBeNull();
    expect(parseClientMessage('{"type":"hello","protocol":"1"}')).toBeNull();
  });

  it('attach : dimensions hors bornes ou non entières', () => {
    const make = (over: Record<string, unknown>): string =>
      JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 80, rows: 24, ...over });
    expect(parseClientMessage(make({ cols: 1 }))).toBeNull();
    expect(parseClientMessage(make({ cols: 1001 }))).toBeNull();
    expect(parseClientMessage(make({ rows: 0 }))).toBeNull();
    expect(parseClientMessage(make({ rows: 1001 }))).toBeNull();
    expect(parseClientMessage(make({ cols: 80.5 }))).toBeNull();
    expect(parseClientMessage(make({ cols: -80 }))).toBeNull();
    expect(parseClientMessage(make({ mode: 'admin' }))).toBeNull();
    expect(parseClientMessage(make({ sessionId: 42 }))).toBeNull();
  });

  it('channel négatif, flottant ou > u32', () => {
    expect(parseClientMessage('{"type":"detach","channel":-1}')).toBeNull();
    expect(parseClientMessage('{"type":"detach","channel":1.5}')).toBeNull();
    expect(parseClientMessage(`{"type":"detach","channel":${0x100000000}}`)).toBeNull();
    expect(parseClientMessage('{"type":"stdin","channel":-1,"data":"x"}')).toBeNull();
    expect(parseClientMessage('{"type":"ack","channel":-1,"bytes":0}')).toBeNull();
  });

  it('stdin : data trop longue ou non-string', () => {
    expect(parseClientMessage(JSON.stringify({ type: 'stdin', channel: 1, data: 'x'.repeat(65537) }))).toBeNull();
    expect(parseClientMessage('{"type":"stdin","channel":1,"data":42}')).toBeNull();
    expect(parseClientMessage('{"type":"stdin","channel":1}')).toBeNull();
  });

  it('answer : action inconnue, optionN manquant/hors bornes, channel invalide', () => {
    expect(parseClientMessage('{"type":"answer","channel":1,"action":"select"}')).toBeNull(); // optionN requis
    expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":0}')).toBeNull();
    expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":100}')).toBeNull();
    expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":1.5}')).toBeNull();
    expect(parseClientMessage('{"type":"answer","channel":1,"action":"nope"}')).toBeNull();
    expect(parseClientMessage('{"type":"answer","channel":-1,"action":"deny"}')).toBeNull();
  });

  it('ack : bytes négatif ou non numérique', () => {
    expect(parseClientMessage('{"type":"ack","channel":1,"bytes":-1}')).toBeNull();
    expect(parseClientMessage('{"type":"ack","channel":1,"bytes":"0"}')).toBeNull();
  });

  it('sub : topics inconnu ou non-tableau', () => {
    expect(parseClientMessage('{"type":"sub","topics":["other"]}')).toBeNull();
    expect(parseClientMessage('{"type":"sub","topics":["sessions","other"]}')).toBeNull();
    expect(parseClientMessage('{"type":"sub","topics":"sessions"}')).toBeNull();
  });
});

describe('parseClientMessage — fuzz rapide', () => {
  it('ne lève jamais et tout message accepté respecte les invariants', () => {
    const rand = mulberry32(0xa5b0e7);
    const types = ['hello', 'attach', 'detach', 'stdin', 'answer', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
    const values: unknown[] = [
      undefined, null, true, false, 0, -1, 1, 1.5, 2, 99, 100, 999, 1000, 1001, 0xffffffff, 0x100000000,
      -0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', 'select', 'confirm', 'deny', {}, [], ['sessions'],
      ['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000),
    ];
    const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T;
    for (let i = 0; i < 1000; i++) {
      const obj: Record<string, unknown> = { type: pick(types) };
      for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics', 'action', 'optionN']) {
        if (rand() < 0.7) obj[key] = pick(values);
      }
      const raw = JSON.stringify(obj);
      let msg: ClientMessage | null = null;
      expect(() => {
        msg = parseClientMessage(raw);
      }).not.toThrow();
      if (msg !== null) assertInvariants(msg);
    }
  });
});

describe('encode/decodeBinaryFrame', () => {
  it('round-trip payload vide', () => {
    const frame = encodeBinaryFrame(BINARY_FRAME.OUTPUT, 0, new Uint8Array(0));
    expect(frame.byteLength).toBe(BINARY_FRAME.HEADER_BYTES);
    const decoded = decodeBinaryFrame(frame);
    expect(decoded.type).toBe(BINARY_FRAME.OUTPUT);
    expect(decoded.channel).toBe(0);
    expect(decoded.payload.byteLength).toBe(0);
  });

  it('round-trip gros payload (1 Mio) et channel max u32', () => {
    const rand = mulberry32(0xdeadbe);
    const payload = new Uint8Array(1024 * 1024);
    for (let i = 0; i < payload.length; i++) payload[i] = Math.floor(rand() * 256);
    const frame = encodeBinaryFrame(BINARY_FRAME.RESYNC, 0xffffffff, payload);
    const decoded = decodeBinaryFrame(frame);
    expect(decoded.type).toBe(BINARY_FRAME.RESYNC);
    expect(decoded.channel).toBe(0xffffffff);
    expect(Buffer.from(decoded.payload).equals(Buffer.from(payload))).toBe(true);
  });

  it('layout : type u8 puis channel u32 little-endian', () => {
    const frame = encodeBinaryFrame(BINARY_FRAME.OUTPUT, 0x01020304, new Uint8Array([0xff]));
    expect([...frame]).toEqual([0x01, 0x04, 0x03, 0x02, 0x01, 0xff]);
  });

  it('octets UTF-8 coupés en frontière de frame : le transport préserve les octets bruts', () => {
    const text = Buffer.from('héllo 😀 wörld', 'utf8');
    const cut = 8; // coupe au milieu d'une séquence multi-octets
    const part1 = new Uint8Array(text.subarray(0, cut));
    const part2 = new Uint8Array(text.subarray(cut));
    const d1 = decodeBinaryFrame(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 7, part1));
    const d2 = decodeBinaryFrame(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 7, part2));
    const reassembled = Buffer.concat([d1.payload, d2.payload]);
    expect(reassembled.equals(text)).toBe(true);
    expect(reassembled.toString('utf8')).toBe('héllo 😀 wörld');
  });

  it('decode sur une vue avec byteOffset non nul (subarray d’un buffer plus large)', () => {
    const payload = new Uint8Array([1, 2, 3]);
    const frame = encodeBinaryFrame(BINARY_FRAME.OUTPUT, 42, payload);
    const padded = new Uint8Array(frame.byteLength + 11);
    padded.set(frame, 11);
    const view = padded.subarray(11);
    const decoded = decodeBinaryFrame(view);
    expect(decoded.type).toBe(BINARY_FRAME.OUTPUT);
    expect(decoded.channel).toBe(42);
    expect([...decoded.payload]).toEqual([1, 2, 3]);
  });
});
