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

/**
 * Tests par PROPRIÉTÉS.
 *
 * Les tests par l'exemple ne couvrent que les cas auxquels on a pensé. Ceux-ci
 * énoncent des invariants et laissent fast-check chercher des contre-exemples
 * sur des milliers d'entrées, y compris celles qu'on n'aurait pas imaginées
 * (chaînes Unicode, -0, 1e21, espaces exotiques…).
 *
 * L'invariant qui compte pour toute cette couche : une entrée arbitraire
 * produit soit une valeur normalisée valide, soit une ValidationError — jamais
 * une exception d'un autre type, jamais NaN. C'est ce qui garantit un 400 et
 * non un 500.
 */

/** Aucune entrée ne doit produire autre chose qu'un résultat ou ValidationError. */
function totality(fn) {
  return (input) => {
    try {
      return { ok: true, value: fn(input) };
    } catch (err) {
      if (!(err instanceof ValidationError)) {
        throw new Error(`exception inattendue (${err.constructor.name}: ${err.message})`);
      }
      return { ok: false };
    }
  };
}

describe("totalité : jamais d'exception hors ValidationError", () => {
  const anything = fc.oneof(
    fc.string(), fc.integer(), fc.double(), fc.constant(null),
    fc.constant(undefined), fc.constant(""), fc.boolean(),
    fc.string({ unit: "grapheme" }),
  );

  it.each([
    ["sanitizeSearchString", (v) => sanitizeSearchString(v)],
    ["parseYear", (v) => parseYear(v)],
    ["parseLat", (v) => parseLat(v)],
    ["parseLon", (v) => parseLon(v)],
    ["parseMaId", (v) => parseMaId(v)],
    ["parseZoom", (v) => parseZoom(v)],
    ["parseBbox", (v) => parseBbox(v)],
  ])("%s", (_n, fn) => {
    fc.assert(fc.property(anything, (v) => { totality(fn)(v); }), { numRuns: 400 });
  });

  it("parseLimitOffset", () => {
    fc.assert(fc.property(anything, anything, (a, b) => {
      totality(() => parseLimitOffset(a, b))(null);
    }), { numRuns: 400 });
  });

  it("pagination ne lève jamais", () => {
    fc.assert(fc.property(
      fc.record({ page: fc.option(anything), pageSize: fc.option(anything) }),
      (q) => { pagination(q); }
    ), { numRuns: 400 });
  });
});

describe("bornes : ce qui sort respecte toujours le contrat", () => {
  it("pagination reste dans ses bornes et l'offset est cohérent", () => {
    fc.assert(fc.property(
      fc.record({ page: fc.oneof(fc.integer(), fc.string()), pageSize: fc.oneof(fc.integer(), fc.string()) }),
      (q) => {
        const { page, pageSize, offset } = pagination(q);
        expect(page).toBeGreaterThanOrEqual(1);
        expect(pageSize).toBeGreaterThanOrEqual(1);
        expect(pageSize).toBeLessThanOrEqual(200);
        // Invariant liant les trois : c'est lui qui garantit une pagination
        // sans trou ni doublon.
        expect(offset).toBe((page - 1) * pageSize);
        expect(Number.isInteger(offset)).toBe(true);
      }
    ), { numRuns: 500 });
  });

  it("parseLimitOffset ne renvoie jamais NaN et respecte le plafond", () => {
    fc.assert(fc.property(
      fc.oneof(fc.integer(), fc.double(), fc.string()),
      fc.oneof(fc.integer(), fc.double(), fc.string()),
      (l, o) => {
        let r;
        try { r = parseLimitOffset(l, o, { maxLimit: 150000 }); }
        catch (e) { expect(e).toBeInstanceOf(ValidationError); return; }
        // Un NaN ici partait dans « LIMIT $n » et donnait un 500.
        expect(Number.isInteger(r.limit)).toBe(true);
        expect(Number.isInteger(r.offset)).toBe(true);
        expect(r.limit).toBeGreaterThanOrEqual(1);
        expect(r.limit).toBeLessThanOrEqual(150000);
        expect(r.offset).toBeGreaterThanOrEqual(0);
      }
    ), { numRuns: 500 });
  });

  it("les coordonnées acceptées sont toujours dans leur domaine", () => {
    fc.assert(fc.property(fc.double({ noNaN: false }), (v) => {
      /** @type {[(v:any)=>number|null, number][]} */
      const cases = [[parseLat, 90], [parseLon, 180]];
      for (const [fn, max] of cases) {
        let r;
        try { r = fn(v); } catch (e) { expect(e).toBeInstanceOf(ValidationError); continue; }
        if (r !== null) {
          expect(Number.isFinite(r)).toBe(true);
          expect(Math.abs(r)).toBeLessThanOrEqual(max);
        }
      }
    }), { numRuns: 500 });
  });

  it("une année acceptée est toujours dans [1800, 2100]", () => {
    fc.assert(fc.property(fc.oneof(fc.integer(), fc.string()), (v) => {
      let r;
      try { r = parseYear(v); } catch (e) { expect(e).toBeInstanceOf(ValidationError); return; }
      if (r !== null) {
        expect(r).toBeGreaterThanOrEqual(1800);
        expect(r).toBeLessThanOrEqual(2100);
      }
    }), { numRuns: 500 });
  });

  it("un ma_id accepté est un entier positif sûr", () => {
    fc.assert(fc.property(fc.oneof(fc.integer(), fc.double(), fc.string()), (v) => {
      let r;
      try { r = parseMaId(v); } catch (e) { expect(e).toBeInstanceOf(ValidationError); return; }
      expect(Number.isSafeInteger(r)).toBe(true);
      expect(r).toBeGreaterThanOrEqual(0);
    }), { numRuns: 500 });
  });

  it("une bbox acceptée est toujours géographiquement cohérente", () => {
    fc.assert(fc.property(
      fc.tuple(fc.double(), fc.double(), fc.double(), fc.double()),
      (parts) => {
        let r;
        try { r = parseBbox(parts.join(",")); }
        catch (e) { expect(e).toBeInstanceOf(ValidationError); return; }
        expect(r.minLon).toBeLessThan(r.maxLon);
        expect(r.minLat).toBeLessThan(r.maxLat);
        expect(r.minLat).toBeGreaterThanOrEqual(-90);
        expect(r.maxLat).toBeLessThanOrEqual(90);
      }
    ), { numRuns: 500 });
  });

  it("la taille de cellule décroît avec le zoom et garde son plancher", () => {
    fc.assert(fc.property(fc.integer({ min: 0, max: 22 }), fc.integer({ min: 0, max: 22 }), (a, b) => {
      const [lo, hi] = a <= b ? [a, b] : [b, a];
      expect(cellSizeForZoom(lo)).toBeGreaterThanOrEqual(cellSizeForZoom(hi));
      expect(cellSizeForZoom(hi)).toBeGreaterThanOrEqual(0.01);
    }), { numRuns: 300 });
  });
});

describe("propriétés de la recherche", () => {
  it("une chaîne acceptée fait entre 2 et 100 caractères et est trimée", () => {
    fc.assert(fc.property(fc.string({ unit: "grapheme" }), (v) => {
      let r;
      try { r = sanitizeSearchString(v); }
      catch (e) { expect(e).toBeInstanceOf(ValidationError); return; }
      expect(r.length).toBeGreaterThanOrEqual(2);
      expect(r.length).toBeLessThanOrEqual(100);
      expect(r).toBe(r.trim());
    }), { numRuns: 600 });
  });

  // Ce qui est rejeté doit le rester quelle que soit l'enveloppe d'espaces :
  // sinon un motif pathologique passerait en le préfixant d'un espace.
  it("les espaces autour ne changent jamais la décision", () => {
    fc.assert(fc.property(
      fc.string({ minLength: 1, maxLength: 60 }),
      fc.stringMatching(/^[ \t]{0,5}$/),
      (core, pad) => {
        const decide = (s) => { try { sanitizeSearchString(s); return true; } catch { return false; } };
        expect(decide(core)).toBe(decide(`${pad}${core}${pad}`));
      }
    ), { numRuns: 400 });
  });

  it("aucun caractère de contrôle ne survit à la validation", () => {
    fc.assert(fc.property(fc.string({ unit: "grapheme", minLength: 2 }), (v) => {
      let r;
      try { r = sanitizeSearchString(v); } catch { return; }
      // eslint-disable-next-line no-control-regex
      expect(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/.test(r)).toBe(false);
    }), { numRuns: 600 });
  });
});

describe("propriétés des listes CSV", () => {
  it("aucun élément vide, et le maximum est respecté", () => {
    fc.assert(fc.property(fc.array(fc.string(), { maxLength: 60 }), (items) => {
      let r;
      try { r = parseCsvList(items.join(","), { max: 50, label: "test" }); }
      catch (e) { expect(e).toBeInstanceOf(ValidationError); return; }
      expect(r.every((x) => x.length > 0)).toBe(true);
      expect(r.length).toBeLessThanOrEqual(50);
    }), { numRuns: 400 });
  });

  it("la transformation est appliquée à chaque élément", () => {
    fc.assert(fc.property(fc.array(fc.string({ minLength: 1 }), { maxLength: 20 }), (items) => {
      const r = parseCsvList(items.join(","), { transform: (s) => s.toUpperCase() });
      expect(r.every((x) => x === x.toUpperCase())).toBe(true);
    }), { numRuns: 300 });
  });
});
