import { describe, expect, it } from "vitest";
import {
  countryCountSchema,
  fetchedAtIso,
  formatLabel,
  importCreatedSchema,
  importListSchema,
  importSchema,
  mappingEquals,
  previewSchema,
  reportSummary,
  suggestedListName,
  toImportReport,
  uploadResultSchema,
} from "./imports";

const IMPORT_ID = "01922b1c-0000-7000-8000-000000000001";
const TARGET_ID = "01922b1c-0000-7000-8000-000000000002";
const JOB_ID = "01922b1c-0000-7000-8000-000000000003";
const SHA = "a".repeat(64);

const anImport = {
  entry: "import",
  id: IMPORT_ID,
  site_target_id: TARGET_ID,
  source: "semrush_positions",
  kind: "csv",
  filename: "positions.csv",
  file_sha256: SHA,
  detected_format: "semrush_positions",
  detected_country: "FR",
  fetched_at: "2026-08-31T00:00:00Z",
  keyword_list_id: null,
  job_id: JOB_ID,
  status: "done",
  error: null,
  report: { rows_read: 10, rows_inserted: 9, errors_csv: null },
  created_at: "2026-08-31T10:00:00Z",
};

describe("uploadResultSchema", () => {
  it("accepte une réponse d'upload sans doublon", () => {
    const parsed = uploadResultSchema.parse({
      sha256: SHA,
      filename: "export.csv",
      size: 1234,
      duplicate_of: null,
    });
    expect(parsed.duplicate_of).toBeNull();
  });

  it("garde l'import en doublon quand l'api en signale un", () => {
    const parsed = uploadResultSchema.parse({
      sha256: SHA,
      filename: "export.csv",
      size: 1,
      duplicate_of: IMPORT_ID,
    });
    expect(parsed.duplicate_of).toBe(IMPORT_ID);
  });

  it("refuse une réponse sans empreinte", () => {
    expect(() => uploadResultSchema.parse({ filename: "x.csv", size: 1 })).toThrow();
  });
});

describe("previewSchema", () => {
  it("parse un aperçu complet et ignore les champs inconnus", () => {
    const parsed = previewSchema.parse({
      format: "generic_csv",
      confidence: 0,
      language: null,
      headers: ["Requête", "Vol."],
      columns: { keyword: "Requête" },
      detected_country: null,
      candidates: [{ source: "semrush_magic", language: "fr", confidence: 0.5 }],
      sample: [
        {
          line: 2,
          keyword: "chaussures",
          volume: 100,
          kd: 12.5,
          cpc: null,
          intent: null,
          serp_features: null,
          trend: [1, 2],
          url: null,
          position: null,
        },
      ],
      warnings: [{ code: "ambiguous_format", detail: "…" }],
      champ_futur: true,
    });
    expect(parsed.sample[0]?.keyword).toBe("chaussures");
    expect(parsed).not.toHaveProperty("champ_futur");
  });

  it("accepte un code d'avertissement inconnu de cette version du web", () => {
    const parsed = previewSchema.parse({
      format: "generic_csv",
      confidence: 0,
      language: null,
      headers: [],
      columns: {},
      detected_country: null,
      candidates: [],
      sample: [],
      warnings: [{ code: "code_inedit", detail: "message" }],
    });
    expect(parsed.warnings[0]?.code).toBe("code_inedit");
  });

  it("porte le mapping proposé par l'api pour un fichier non reconnu", () => {
    const parsed = previewSchema.parse({
      format: "generic_csv",
      confidence: 0,
      language: null,
      headers: ["Keyword", "Avg. Search Volume (Last 12 months)"],
      columns: { keyword: "Keyword", volume: "Avg. Search Volume (Last 12 months)" },
      suggested_mapping: { keyword: "Keyword", volume: "Avg. Search Volume (Last 12 months)" },
      detected_country: null,
      candidates: [],
      sample: [],
      warnings: [{ code: "mapping_suggested", detail: "…" }],
    });
    expect(parsed.suggested_mapping?.volume).toBe("Avg. Search Volume (Last 12 months)");
  });

  it("accepte un aperçu rendu par une api antérieure à la proposition de mapping", () => {
    // Web et api se déploient séparément : un champ manquant ne doit pas casser l'écran.
    const parsed = previewSchema.parse({
      format: "generic_csv",
      confidence: 0,
      language: null,
      headers: [],
      columns: {},
      detected_country: null,
      candidates: [],
      sample: [],
      warnings: [],
    });
    expect(parsed.suggested_mapping ?? null).toBeNull();
  });

  it("parse l'aperçu d'une api antérieure au recensement des pays", () => {
    // Sens 1 du déploiement croisé : le web part le premier (Vercel), l'api rend encore
    // `detected_country` et ignore tout de `countries`. Le wizard doit pouvoir itérer sans
    // garde, donc `countries` vaut `[]` et non `undefined`.
    const parsed = previewSchema.parse({
      format: "mangools",
      confidence: 1,
      language: "en",
      headers: ["Keyword", "Location"],
      columns: { keyword: "Keyword" },
      detected_country: "US",
      candidates: [],
      sample: [],
      warnings: [],
    });
    expect(parsed.countries).toEqual([]);
    expect(parsed.detected_country).toBe("US");
  });

  it("parse l'aperçu d'une api qui a remplacé le pays deviné par le recensement", () => {
    // Sens 2 : l'api est passée (Dedibox), `detected_country` a disparu de `PreviewOut`. Une
    // clé requise ferait ici lever `previewImportAction` et emporter la page au `error.tsx`.
    const parsed = previewSchema.parse({
      format: "mangools",
      confidence: 1,
      language: "en",
      headers: ["Keyword", "Location"],
      columns: { keyword: "Keyword" },
      countries: [
        {
          raw: "United States",
          code: "US",
          label: "United States",
          rows: 74,
          suggested_language: "en",
          target_ids: [TARGET_ID],
        },
        {
          raw: "New York",
          code: null,
          label: "New York",
          rows: 3,
          suggested_language: null,
          target_ids: [],
        },
      ],
      candidates: [],
      sample: [],
      warnings: [],
    });
    expect(parsed.detected_country ?? null).toBeNull();
    expect(parsed.countries.map((entry) => entry.raw)).toEqual(["United States", "New York"]);
    // Une localité est recensée avec `code: null` — montrée pour révéler le trou de table,
    // jamais proposée à l'import.
    expect(parsed.countries[1]?.code).toBeNull();
    expect(parsed.countries[0]?.target_ids).toEqual([TARGET_ID]);
  });
});

describe("countryCountSchema", () => {
  it("traite l'absence de cible existante comme une liste vide", () => {
    const parsed = countryCountSchema.parse({
      raw: "Germany",
      code: "DE",
      label: "Germany",
      rows: 10,
      suggested_language: "de",
    });
    expect(parsed.target_ids).toEqual([]);
  });

  it("refuse une entrée dont le décompte n'est pas un nombre", () => {
    // La tolérance porte sur le CHAMP absent, pas sur son contenu : un recensement mal formé
    // est une rupture de contrat, pas une version antérieure de l'api.
    expect(() =>
      countryCountSchema.parse({
        raw: "Germany",
        code: "DE",
        label: "Germany",
        rows: "beaucoup",
        suggested_language: "de",
      }),
    ).toThrow();
  });
});

describe("mappingEquals", () => {
  it("reconnaît le même câblage quel que soit l'ordre des champs", () => {
    expect(mappingEquals({ keyword: "A", volume: "B" }, { volume: "B", keyword: "A" })).toBe(true);
  });

  it("distingue un champ ajouté, retiré ou recâblé", () => {
    expect(mappingEquals({ keyword: "A" }, { keyword: "A", volume: "B" })).toBe(false);
    expect(mappingEquals({ keyword: "A", volume: "B" }, { keyword: "A" })).toBe(false);
    expect(mappingEquals({ keyword: "A" }, { keyword: "B" })).toBe(false);
  });

  it("traite l'absence de mapping comme un mapping vide", () => {
    expect(mappingEquals(null, {})).toBe(true);
    expect(mappingEquals(undefined, { keyword: "A" })).toBe(false);
  });
});

describe("importListSchema", () => {
  it("discrimine imports et événements de rattachement", () => {
    const parsed = importListSchema.parse({
      items: [
        anImport,
        {
          entry: "map_reconcile",
          id: JOB_ID,
          site_target_id: TARGET_ID,
          status: "done",
          report: { resolved: 3 },
          error: null,
          created_at: "2026-08-31T11:00:00Z",
        },
      ],
      next_cursor: null,
    });
    expect(parsed.items.map((item) => item.entry)).toEqual(["import", "map_reconcile"]);
  });

  it("refuse une entrée sans discriminant", () => {
    expect(() =>
      importListSchema.parse({ items: [{ id: IMPORT_ID }], next_cursor: null }),
    ).toThrow();
  });
});

describe("importCreatedSchema", () => {
  it("lit le champ sérialisé sous l'alias « import »", () => {
    const parsed = importCreatedSchema.parse({ import: anImport, job_id: JOB_ID });
    expect(parsed.import.id).toBe(IMPORT_ID);
    expect(parsed.job_id).toBe(JOB_ID);
  });
});

describe("importSchema", () => {
  it("accepte un rapport absent (import encore en file)", () => {
    const parsed = importSchema.parse({ ...anImport, status: "queued", report: null });
    expect(parsed.report).toBeNull();
  });

  it("conserve les compteurs inconnus du rapport", () => {
    const parsed = importSchema.parse({
      ...anImport,
      report: { rows_read: 1, compteur_futur: 7 },
    });
    expect(parsed.report?.["compteur_futur"]).toBe(7);
  });
});

describe("toImportReport", () => {
  it("rend null pour un rapport absent", () => {
    expect(toImportReport(null)).toBeNull();
  });

  it("typé les compteurs connus d'un jsonb libre", () => {
    expect(toImportReport({ rows_read: 5 })?.rows_read).toBe(5);
  });

  it("rend null plutôt que de lever sur un rapport d'une autre forme", () => {
    expect(toImportReport({ rows_read: "beaucoup" })).toBeNull();
  });

  it("type la ventilation par pays du rapport ventilé", () => {
    // `looseObject` laisserait passer ces clés sans les typer : sans déclaration explicite,
    // la répartition n'est affichable nulle part (§4.5).
    const report = toImportReport({
      rows_ignored: 155,
      rows_off_target: 152,
      rows_country_unresolved: 3,
      ignored_by_country: { "United Kingdom": 55, Australia: 32 },
      unresolved_by_country: { "New York": 3 },
    });
    expect(report?.rows_off_target).toBe(152);
    expect(report?.ignored_by_country?.["United Kingdom"]).toBe(55);
    expect(report?.unresolved_by_country?.["New York"]).toBe(3);
  });
});

describe("reportSummary", () => {
  it("rend « — » sans rapport", () => {
    expect(reportSummary(null)).toBe("—");
  });

  it("résume les quatre compteurs d'un rapport antérieur au cloisonnement", () => {
    // Aucune clé de ventilation : le résumé reste celui d'avant, sans parenthèse.
    expect(
      reportSummary({ rows_read: 1200, rows_inserted: 1180, rows_ignored: 0, rows_error: 20 }),
    ).toBe("1200 lues · 1180 insérées · 0 ignorées · 20 en erreur");
  });

  it("ventile les lignes ignorées par motif quand le rapport les porte", () => {
    expect(
      reportSummary({
        rows_read: 1934,
        rows_inserted: 74,
        rows_ignored: 155,
        rows_error: 0,
        rows_off_target: 152,
        rows_country_unresolved: 3,
      }),
    ).toBe(
      "1934 lues · 74 insérées · 155 ignorées (hors cible 152, pays non reconnu 3) · 0 en erreur",
    );
  });

  it("n'affiche que le motif qui a réellement écarté des lignes", () => {
    expect(reportSummary({ rows_ignored: 3, rows_off_target: 0, rows_country_unresolved: 3 })).toBe(
      "3 ignorées (pays non reconnu 3)",
    );
  });

  it("tait la ventilation d'une source sans colonne pays", () => {
    // Semrush Magic : le filtre est un no-op, les deux motifs valent zéro. « 1 ignorées
    // (hors cible 0, pays non reconnu 0) » sur chaque ligne du listing serait du bruit.
    expect(
      reportSummary({
        rows_read: 503,
        rows_inserted: 499,
        rows_ignored: 1,
        rows_error: 3,
        rows_off_target: 0,
        rows_country_unresolved: 0,
      }),
    ).toBe("503 lues · 499 insérées · 1 ignorées · 3 en erreur");
  });
});

describe("fetchedAtIso", () => {
  /** Le jour local que désigne un instant ISO — c'est lui qui date les métriques. */
  const localDay = (iso: string): string => {
    const date = new Date(iso);
    return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
      date.getDate(),
    ).padStart(2, "0")}`;
  };

  it("rend un instant complet avec fuseau pour le jour donné", () => {
    const iso = fetchedAtIso("2026-08-31");
    // Toujours suffixé `Z` : c'est ce qu'exige `z.iso.datetime()` puis le validateur
    // `_tz_aware` de l'api. Le jour local doit rester celui donné, quel que soit le fuseau.
    expect(iso).toMatch(/Z$/);
    expect(localDay(iso)).toBe("2026-08-31");
  });

  it("garde le 29 février d'une année bissextile", () => {
    expect(localDay(fetchedAtIso("2028-02-29"))).toBe("2028-02-29");
  });

  it("retombe sur aujourd'hui plutôt que de lever sur une chaîne inattendue", () => {
    // Le champ n'existe plus à l'écran : une chaîne hors format ne peut venir que d'un
    // appelant fautif, et un import ne doit jamais partir au `error.tsx` sur sa date.
    const today = localDay(new Date().toISOString());
    for (const input of ["", "2026-08", "31/08/2026", "0002-08-3"]) {
      expect(localDay(fetchedAtIso(input))).toBe(today);
    }
  });

  it("retombe sur aujourd'hui pour une date qui n'existe pas plutôt que de la recaler", () => {
    // `new Date(2026, 1, 30)` rendrait le 2 mars sans rien signaler.
    const today = localDay(new Date().toISOString());
    for (const input of ["2026-02-30", "2026-13-01", "2026-00-10", "2027-02-29"]) {
      expect(localDay(fetchedAtIso(input))).toBe(today);
    }
  });
});

describe("formatLabel et suggestedListName", () => {
  it("libelle un adaptateur connu", () => {
    expect(formatLabel("semrush_positions")).toBe("Semrush — Organic Positions");
  });

  it("retombe sur le nom brut d'un adaptateur ajouté côté api", () => {
    expect(formatLabel("dataforseo")).toBe("dataforseo");
  });

  it("propose « Semrush — <date> » comme nom de liste", () => {
    expect(suggestedListName("semrush_positions", "2026-08-31")).toBe("Semrush — 2026-08-31");
  });
});
