import { describe, it, expect } from "vitest";
import {
  ValidationError,
  sanitizeSearchString,
  parseLimitOffset,
  pagination,
  parseBbox,
  parseZoom,
  cellSizeForZoom,
  parseCsvList,
  parseYear,
  parseLat,
  parseLon,
  parseMaId,
} from "../src/validate.js";

describe("sanitizeSearchString", () => {
  it("trim et accepte une recherche normale", () => {
    expect(sanitizeSearchString("  mayhem  ")).toBe("mayhem");
  });

  it.each([
    ["trop courte", "a"],
    ["vide", "   "],
    ["trop longue", "x".repeat(101)],
  ])("rejette une chaîne %s", (_label, input) => {
    expect(() => sanitizeSearchString(input)).toThrow(ValidationError);
  });

  it("accepte pile 2 et 100 caractères (bornes incluses)", () => {
    expect(sanitizeSearchString("ab")).toBe("ab");
    expect(sanitizeSearchString("y".repeat(100))).toHaveLength(100);
  });

  it("rejette les caractères de contrôle", () => {
    expect(() => sanitizeSearchString("may\x00hem")).toThrow(/caractères invalides/);
    expect(() => sanitizeSearchString("may\x7Fhem")).toThrow(/caractères invalides/);
  });

  it("laisse passer tab et newline (hors plage de contrôle bloquée)", () => {
    expect(sanitizeSearchString("a\tb")).toBe("a\tb");
  });

  it("rejette les patterns ILIKE pathologiques", () => {
    expect(() => sanitizeSearchString("%%%")).toThrow(/patterns invalides/);
    expect(() => sanitizeSearchString("_".repeat(10))).toThrow(/patterns invalides/);
    expect(() => sanitizeSearchString("%".repeat(10))).toThrow(/patterns invalides/);
  });

  it("autorise un % isolé (recherche partielle légitime)", () => {
    expect(sanitizeSearchString("100%")).toBe("100%");
  });

  it("utilise le nom de champ dans le message", () => {
    expect(() => sanitizeSearchString("a", "Genre")).toThrow(/^Genre /);
  });
});

describe("parseLimitOffset", () => {
  it("applique les valeurs par défaut", () => {
    expect(parseLimitOffset(undefined, undefined)).toEqual({ limit: 1000, offset: 0 });
    expect(parseLimitOffset("", "")).toEqual({ limit: 1000, offset: 0 });
  });

  it("borne le limit au maximum", () => {
    expect(parseLimitOffset("999999", "0", { maxLimit: 150000 }).limit).toBe(150000);
  });

  it("tronque les valeurs fractionnaires", () => {
    expect(parseLimitOffset("10.9", "5.9")).toEqual({ limit: 10, offset: 5 });
  });

  // Régression : `Number("abc")` donnait NaN, propagé jusqu'au LIMIT $n
  // → erreur pg → 500 au lieu d'un 400 explicite.
  it("rejette un limit non numérique au lieu de propager NaN", () => {
    expect(() => parseLimitOffset("abc", "0")).toThrow(ValidationError);
    expect(() => parseLimitOffset("0", "0")).toThrow(/limit invalide/);
    expect(() => parseLimitOffset("-5", "0")).toThrow(/limit invalide/);
  });

  it("rejette un offset non numérique ou négatif", () => {
    expect(() => parseLimitOffset("10", "abc")).toThrow(/offset invalide/);
    expect(() => parseLimitOffset("10", "-1")).toThrow(/offset invalide/);
  });

  it("rejette un offset au-delà du million", () => {
    expect(() => parseLimitOffset("10", "1000001")).toThrow(/Offset trop grand/);
    expect(parseLimitOffset("10", "1000000").offset).toBe(1000000);
  });
});

describe("pagination", () => {
  it("valeurs par défaut", () => {
    expect(pagination({})).toEqual({ page: 1, pageSize: 50, offset: 0 });
  });

  it("calcule l'offset", () => {
    expect(pagination({ page: "3", pageSize: "20" })).toEqual({ page: 3, pageSize: 20, offset: 40 });
  });

  it("borne pageSize à 200 et page à 1 minimum", () => {
    expect(pagination({ pageSize: "10000" }).pageSize).toBe(200);
    expect(pagination({ page: "-4" }).page).toBe(1);
    expect(pagination({ page: "abc" }).page).toBe(1);
  });
});

describe("parseBbox", () => {
  it("parse une bbox valide", () => {
    expect(parseBbox("-5,40,10,55")).toEqual({ minLon: -5, minLat: 40, maxLon: 10, maxLat: 55 });
  });

  it.each([
    ["absente", ""],
    ["3 valeurs", "1,2,3"],
    ["5 valeurs", "1,2,3,4,5"],
    ["non numérique", "a,b,c,d"],
    ["hors bornes lat", "-5,40,10,95"],
    ["hors bornes lon", "-181,40,10,55"],
    ["minLon >= maxLon", "10,40,10,55"],
    ["minLat >= maxLat", "-5,55,10,40"],
  ])("rejette une bbox %s", (_label, input) => {
    expect(() => parseBbox(input)).toThrow(ValidationError);
  });
});

describe("parseZoom / cellSizeForZoom", () => {
  it("accepte les bornes 0 et 22", () => {
    expect(parseZoom("0")).toBe(0);
    expect(parseZoom("22")).toBe(22);
  });

  it("rejette hors bornes et non numérique", () => {
    expect(() => parseZoom("-1")).toThrow(/between 0 and 22/);
    expect(() => parseZoom("23")).toThrow(/between 0 and 22/);
    expect(() => parseZoom("abc")).toThrow(ValidationError);
  });

  it("la taille de cellule décroît avec le zoom et a un plancher", () => {
    expect(cellSizeForZoom(0)).toBe(180);
    expect(cellSizeForZoom(4)).toBeCloseTo(11.25);
    expect(cellSizeForZoom(22)).toBe(0.01);
    expect(cellSizeForZoom(11)).toBeGreaterThan(cellSizeForZoom(12));
  });
});

describe("parseCsvList", () => {
  it("découpe, trim et filtre les vides", () => {
    expect(parseCsvList("fr, de ,, be")).toEqual(["fr", "de", "be"]);
  });

  it("applique la transformation", () => {
    expect(parseCsvList("fr,de", { transform: (s) => s.toUpperCase() })).toEqual(["FR", "DE"]);
  });

  it("rejette au-delà du maximum", () => {
    const many = Array.from({ length: 101 }, (_, i) => `c${i}`).join(",");
    expect(() => parseCsvList(many, { max: 100, label: "pays" })).toThrow(/Max 100 pays/);
    expect(parseCsvList(many, { max: 101, label: "pays" })).toHaveLength(101);
  });
});

describe("parseYear", () => {
  it("renvoie null pour une entrée vide", () => {
    expect(parseYear("")).toBeNull();
    expect(parseYear(null)).toBeNull();
    expect(parseYear(undefined)).toBeNull();
  });

  it("accepte les bornes 1800 et 2100", () => {
    expect(parseYear("1800")).toBe(1800);
    expect(parseYear("2100")).toBe(2100);
  });

  it("rejette hors bornes et non numérique", () => {
    expect(() => parseYear("1799")).toThrow(ValidationError);
    expect(() => parseYear("2101")).toThrow(ValidationError);
    expect(() => parseYear("abc", "year_min")).toThrow(/^year_min invalide/);
  });
});

describe("parseLat / parseLon", () => {
  it("accepte les bornes", () => {
    expect(parseLat(-90)).toBe(-90);
    expect(parseLat(90)).toBe(90);
    expect(parseLon(-180)).toBe(-180);
    expect(parseLon(180)).toBe(180);
  });

  it("null efface la valeur", () => {
    expect(parseLat(null)).toBeNull();
    expect(parseLon("")).toBeNull();
  });

  it("rejette hors bornes", () => {
    expect(() => parseLat(90.1)).toThrow(/lat invalide/);
    expect(() => parseLon(-180.1)).toThrow(/lon invalide/);
    expect(() => parseLat("nord")).toThrow(/lat invalide/);
  });

  // 0 est une coordonnée valide (golfe de Guinée) : ne doit pas être traité
  // comme une valeur vide par un test de falsiness.
  it("accepte 0", () => {
    expect(parseLat(0)).toBe(0);
    expect(parseLon(0)).toBe(0);
  });
});

describe("parseMaId", () => {
  it("accepte un entier positif, y compris au-delà de 2^31", () => {
    expect(parseMaId("3500000000")).toBe(3500000000);
    expect(parseMaId(0)).toBe(0);
  });

  it("rejette négatif, décimal et non numérique", () => {
    expect(() => parseMaId("-1")).toThrow(/bad ma_id/);
    expect(() => parseMaId("1.5")).toThrow(/bad ma_id/);
    expect(() => parseMaId("abc")).toThrow(/bad ma_id/);
  });
});
