import { describe, expect, it } from 'vitest';
import {
  MAX_NICKNAME_LENGTH,
  containsProfanity,
  dedupeNickname,
  findProfanity,
  foldForProfanity,
  nicknameKey,
  normalizeNickname,
  takenNicknameKeys,
  validateNickname,
  validateTeamName,
} from '../src/index';
import { Game } from './helpers';

const reason = (raw: string): string | null => {
  const v = validateNickname(raw);
  return v.ok ? null : v.reason;
};

describe('normalization', () => {
  it('trims, collapses whitespace and applies NFKC', () => {
    expect(normalizeNickname('  Mike   the\tGreat  ')).toBe('Mike the Great');
    expect(normalizeNickname('Ｍｉｋｅ')).toBe('Mike'); // full-width → ASCII
    expect(normalizeNickname('ﬁona')).toBe('fiona'); // ligature
    expect(normalizeNickname('José')).toBe('José'); // composed
  });

  it('nicknameKey is case-insensitive', () => {
    expect(nicknameKey(' MIKE ')).toBe(nicknameKey('mike'));
    expect(nicknameKey('Ｍｉｋｅ')).toBe('mike');
  });
});

describe('validateNickname', () => {
  it('accepts letters of any script, digits and . _ - \' !', () => {
    for (const ok of ['Mike', 'José', 'Zoë', '李小龙', 'Сергей', 'नमस्ते', "O'Brien", 'Dr. Who!', 'Ana-Maria', 'DJ_Spin', 'R2D2', 'Al']) {
      expect(validateNickname(ok), ok).toEqual({ ok: true, nickname: ok });
    }
    expect(validateNickname('  Quiz   Wiz ')).toEqual({ ok: true, nickname: 'Quiz Wiz' });
  });

  it('enforces 2–16 characters (code points) after normalization', () => {
    expect(reason('A')).toBe('TOO_SHORT');
    expect(reason('   a   ')).toBe('TOO_SHORT');
    expect(reason('')).toBe('TOO_SHORT');
    expect(reason('x'.repeat(16))).toBeNull();
    expect(reason('x'.repeat(17))).toBe('TOO_LONG');
    expect(reason('李'.repeat(16))).toBeNull();
  });

  it('rejects other characters, emoji and names without a letter or digit', () => {
    for (const bad of ['Mike<3', 'a@b', '🍺 Beer', 'semi;colon', '...', "-'-", 'tab/slash', 'zero​width']) {
      expect(reason(bad), bad).toBe('INVALID_CHARACTERS');
    }
  });

  it('rejects web addresses', () => {
    for (const bad of ['www.pints.com', 'bit.ly', 'Visit http', 'shop . io', 'mysite.co']) expect(reason(bad), bad).toBe('URL');
    for (const ok of ['Dr. Who', 'J. R. Smith', 'St. Louis']) expect(reason(ok), ok).toBeNull();
  });

  it('rejects reserved staff names', () => {
    expect(reason('Admin')).toBe('RESERVED');
    expect(reason('HOST')).toBe('RESERVED');
    expect(reason('PintRush')).toBe('RESERVED');
  });

  it('rejects profanity including leetspeak, stretching and spacing tricks', () => {
    for (const bad of ['Fuck', 'Sh1t Head', 'SH!T', 'fuuuuuck', 'f u c k', 's.h.i.t', 'B1tch', 'Dick', 'Ass', 'b4stard', '5lut', 'Fück', 'shiiit']) {
      expect(reason(bad), bad).toBe('PROFANITY');
    }
  });

  it('validateTeamName allows up to 24 characters', () => {
    expect(validateTeamName('The Quizzly Bears United')).toEqual({ ok: true, nickname: 'The Quizzly Bears United' });
    expect(validateTeamName('x'.repeat(25))).toMatchObject({ ok: false, reason: 'TOO_LONG' });
    expect(validateTeamName('Pint Sh1ts')).toMatchObject({ ok: false, reason: 'PROFANITY' });
  });
});

describe('profanity filter', () => {
  it('folds leetspeak and diacritics', () => {
    expect(foldForProfanity('0134 57@$')).toBe('oiea stas');
    expect(foldForProfanity('Sh!t')).toBe('shit');
    expect(foldForProfanity('Wow!')).toBe('wow!'); // trailing ! is punctuation, not a letter
    expect(foldForProfanity('Ünïcödé')).toBe('unicode');
  });

  it('catches $ and @ substitutions', () => {
    expect(findProfanity('A$$')).toBe('ass');
    expect(findProfanity('@ss')).toBe('ass');
    expect(findProfanity('$hit')).toBe('shit');
  });

  it('avoids Scunthorpe-style false positives (documented choices)', () => {
    const innocent = [
      'Scunthorpe', 'Cassandra', 'Grape', 'Drape', 'Shitake', 'Shiitake', 'Dickens', 'Dickinson', 'Hancock',
      'Peacock', 'Cocktail', 'Therapist', 'Analyst', 'Penistone', 'Spices', 'Bass', 'Classic', 'Assassin',
      'Raccoon', 'Cucumber', 'Essex', 'Yamashita', 'Matsushita', 'Milford', 'Swanky', 'Niggle', 'Sniggers',
      'Massive', 'Passion', 'Document', 'Hello', 'Tit for Tat', 'As You Wish',
    ];
    for (const word of innocent) expect(findProfanity(word), word).toBeNull();
  });

  it('safe hosts do not shield real words around them', () => {
    expect(findProfanity('shiiitake')).toBe('shit'); // safe patterns match exactly, not stretched
    expect(findProfanity('shitakeshit')).toBe('shit');
    expect(findProfanity('Scunthorpe Fuck')).toBe('fuck');
    expect(containsProfanity('shitass')).toBe(true);
  });

  it('mild words only match as whole words', () => {
    expect(findProfanity('Dick')).toBe('dick');
    expect(findProfanity('Moby Dick')).toBe('dick');
    expect(findProfanity('Dickens')).toBeNull();
    expect(findProfanity('Bad Ass')).toBe('ass');
    expect(findProfanity('Badass')).toBeNull();
  });
});

describe('dedupeNickname', () => {
  it('suffixes duplicates with " 2", " 3", … case-insensitively', () => {
    expect(dedupeNickname('Mike', [])).toBe('Mike');
    expect(dedupeNickname('Mike', ['mike'])).toBe('Mike 2');
    expect(dedupeNickname('mike', new Set(['mike', 'mike 2']))).toBe('mike 3');
    expect(dedupeNickname('MIKE', ['Mike', 'MIKE 2', 'mike 3'])).toBe('MIKE 4');
    expect(dedupeNickname('  Mike  ', ['mike'])).toBe('Mike 2');
  });

  it('trims the base so the result never exceeds 16 characters', () => {
    const long = 'Abcdefghijklmnop'; // 16
    expect(dedupeNickname(long, [long.toLowerCase()])).toBe('Abcdefghijklmn 2');
    // A cut that ends on a space is tidied.
    expect(dedupeNickname('Abcdefghijklm op', ['abcdefghijklm op'])).toBe('Abcdefghijklm 2');
    const taken = [long, ...Array.from({ length: 8 }, (_, i) => `Abcdefghijklmn ${i + 2}`)].map(nicknameKey);
    const tenth = dedupeNickname(long, taken);
    expect(tenth).toBe('Abcdefghijklm 10');
    expect(Array.from(tenth).length).toBeLessThanOrEqual(MAX_NICKNAME_LENGTH);
    const cjk = '李'.repeat(16);
    expect(Array.from(dedupeNickname(cjk, [cjk])).length).toBe(16);
  });

  it('works with takenNicknameKeys from a live session (kicked names are free)', () => {
    const g = Game.create();
    g.join('p1', 'Mike');
    g.join('p2', 'Mike 2');
    expect(dedupeNickname('mike', takenNicknameKeys(g.state))).toBe('mike 3');
    g.must({ type: 'KICK_PLAYER', now: g.now, playerId: 'p1' });
    expect(dedupeNickname('mike', takenNicknameKeys(g.state))).toBe('mike');
  });
});
