import * as z from "zod";

// Frontière réseau du module Imports : toute réponse d'`apiFetch` — et la réponse de
// l'upload direct, seul appel client autorisé — traverse ces schémas.
// Source de vérité des champs : `api/app/imports/schemas.py`.
// Les dates sont typées `z.string()` (pas `z.iso.datetime()`) : le web les réaffiche,
// il ne calcule jamais dessus. Les objets ne sont pas `strict` : un champ ajouté côté
// api ne doit jamais casser un écran.

/** Plafond de l'upload — miroir de `MAX_UPLOAD_BYTES` (`api/app/imports/upload.py`). */
export const MAX_UPLOAD_BYTES = 256 * 1024 * 1024;

/**
 * Adaptateurs connus — miroir du registre `api/app/keywords/sources/__init__.py`.
 * L'api n'expose pas la liste : elle est recopiée ici pour peupler le Select d'override
 * du format. Un nom inconnu de l'api serait de toute façon refusé par un 422, et un
 * adaptateur ajouté côté api reste affichable grâce au repli de `formatLabel`.
 */
export const IMPORT_FORMATS = [
  { value: "semrush_positions", label: "Semrush — Organic Positions" },
  { value: "semrush_magic", label: "Semrush — Keyword Magic" },
  { value: "mangools", label: "Mangools — KWFinder" },
  { value: "generic_csv", label: "CSV générique (mapping manuel)" },
] as const;

/** Le seul adaptateur qui exige un mapping manuel des colonnes (spec §5). */
export const GENERIC_FORMAT = "generic_csv";

export function formatLabel(name: string | null | undefined): string {
  if (!name) return "—";
  return IMPORT_FORMATS.find((format) => format.value === name)?.label ?? name;
}

export const IMPORT_STATUS_LABELS: Record<string, string> = {
  queued: "En file",
  running: "En cours",
  done: "Terminé",
  failed: "Échec",
};

export const uploadResultSchema = z.object({
  sha256: z.string(),
  filename: z.string(),
  size: z.number(),
  // Import existant portant le même sha256 **sur la même cible** : un avertissement, pas
  // un refus — le re-import après correction d'adaptateur est légitime (§1.3).
  duplicate_of: z.uuid().nullable(),
});

export const previewRowSchema = z.object({
  line: z.number(),
  keyword: z.string(),
  volume: z.number().nullable(),
  kd: z.number().nullable(),
  cpc: z.number().nullable(),
  intent: z.string().nullable(),
  serp_features: z.array(z.string()).nullable(),
  trend: z.array(z.number()).nullable(),
  url: z.string().nullable(),
  position: z.number().nullable(),
});

export const previewWarningSchema = z.object({
  // `z.string()` et non l'énumération de l'api : un code d'avertissement ajouté plus tard
  // doit s'afficher, pas faire échouer l'aperçu — le `detail` porte déjà le message.
  code: z.string(),
  detail: z.string(),
});

export const detectionSchema = z.object({
  source: z.string(),
  language: z.string(),
  confidence: z.number(),
});

/**
 * Un pays recensé par l'aperçu (§4.2) — miroir de `CountryCount` (`api/app/imports/schemas.py`).
 *
 * `raw` est le libellé tel qu'il est écrit dans le fichier (« United States », « New York ») ;
 * `code` son alpha-2 ISO 3166-1 quand il se résout, `null` sinon — une localité, un nom absent
 * des tables. Un pays sans code est montré pour révéler le trou, jamais proposé à l'import.
 *
 * `target_ids` est un tableau et non un identifiant unique : côté api l'unicité porte sur le
 * TRIPLET (site, pays, langue), un même pays peut donc déjà porter `en-GB` et `fr-GB`. Vide =
 * aucune cible du site ne vise ce pays, il reste à créer.
 */
export const countryCountSchema = z.object({
  raw: z.string(),
  code: z.string().nullable(),
  label: z.string(),
  rows: z.number(),
  suggested_language: z.string().nullable(),
  target_ids: z.array(z.uuid()).default([]),
});

export const previewSchema = z.object({
  format: z.string(),
  confidence: z.number(),
  language: z.string().nullable(),
  headers: z.array(z.string()),
  columns: z.record(z.string(), z.string()),
  // Mapping proposé par l'api quand aucun adaptateur ne reconnaît le fichier — miroir de
  // `PreviewOut.suggested_mapping`. `nullish` et non `nullable` : web et api se déploient
  // séparément (Vercel / Dedibox), et un aperçu rendu par une api antérieure au champ doit
  // s'afficher plutôt que de casser l'écran d'import en pleine session.
  suggested_mapping: z.record(z.string(), z.string()).nullish(),
  // Le pays de l'aperçu, dans ses deux âges. `detected_country` est l'ancien : un code deviné
  // sur les cinq premières lignes, que l'api cesse de rendre au profit du recensement complet
  // (§4.2). `nullish` pour la raison inverse de `suggested_mapping` — ici c'est l'api qui PERD
  // le champ pendant que ce web tourne encore. Une clé requise ferait lever le `.parse()` de
  // `previewImportAction`, qui l'exécute DANS son `try` alors que `relay()` re-lève tout ce qui
  // n'est pas une `ApiError` : la ZodError remonte, la Server Action rejette, `error.tsx` prend
  // toute la page, et l'utilisateur perd son fichier et son aperçu en pleine session.
  detected_country: z.string().nullish(),
  // `default([])` et non `.optional()` : le wizard doit pouvoir faire `preview.countries.map(…)`
  // sans garde, y compris devant une api antérieure au recensement.
  countries: z.array(countryCountSchema).default([]),
  candidates: z.array(detectionSchema),
  sample: z.array(previewRowSchema),
  warnings: z.array(previewWarningSchema),
});

/**
 * Rapport final du job `import_csv` (`_report`, `api/app/keywords/pipeline.py`).
 * `looseObject` : le rapport est du jsonb, les compteurs ajoutés plus tard survivent au
 * parse, et chaque champ reste facultatif — un rapport partiel (job dépossédé) se rend
 * quand même.
 */
export const importReportSchema = z.looseObject({
  rows_read: z.number().optional(),
  rows_inserted: z.number().optional(),
  rows_ignored: z.number().optional(),
  rows_error: z.number().optional(),
  keywords_created: z.number().optional(),
  metrics_written: z.number().optional(),
  list_items_added: z.number().optional(),
  maps_written: z.number().optional(),
  metrics_deduplicated_in_file: z.number().optional(),
  rows_duplicated_in_file: z.number().optional(),
  // Ventilation du cloisonnement par pays (§4.5). `rows_ignored` en reste le TOTAL — il agrège
  // aussi le rejeu d'un fichier déjà importé ; ces deux motifs-là en sont des sous-ensembles.
  // Les dictionnaires donnent le détail par libellé BRUT (`{"United Kingdom": 55, …}`).
  // Déclarés explicitement bien que `looseObject` les laisserait passer : sans type, la
  // ventilation n'est affichable nulle part. Facultatifs comme le reste — les imports
  // antérieurs et les sources sans colonne pays n'en portent aucun.
  rows_off_target: z.number().optional(),
  rows_country_unresolved: z.number().optional(),
  ignored_by_country: z.record(z.string(), z.number()).optional(),
  unresolved_by_country: z.record(z.string(), z.number()).optional(),
  errors: z.array(z.object({ line: z.number(), reason: z.string() })).optional(),
  errors_total: z.number().optional(),
  errors_csv: z.string().nullable().optional(),
  errors_complete: z.boolean().optional(),
});

export const importSchema = z.object({
  entry: z.literal("import"),
  id: z.uuid(),
  site_target_id: z.uuid(),
  source: z.string(),
  kind: z.string(),
  filename: z.string().nullable(),
  file_sha256: z.string().nullable(),
  detected_format: z.string().nullable(),
  detected_country: z.string().nullable(),
  fetched_at: z.string(),
  keyword_list_id: z.uuid().nullable(),
  job_id: z.uuid(),
  status: z.string(),
  error: z.string().nullable(),
  report: importReportSchema.nullable(),
  created_at: z.string(),
});

/**
 * Un job `map_reconcile` rendu comme événement de l'onglet Imports (§1.2, §12.2). Tant que
 * le module Crawl n'existe pas, aucun n'est enfilé : le listing en est simplement dépourvu.
 */
export const reconcileEventSchema = z.object({
  entry: z.literal("map_reconcile"),
  id: z.uuid(),
  site_target_id: z.uuid().nullable(),
  status: z.string(),
  report: z.record(z.string(), z.unknown()).nullable(),
  error: z.string().nullable(),
  created_at: z.string(),
});

export const importEntrySchema = z.discriminatedUnion("entry", [
  importSchema,
  reconcileEventSchema,
]);

export const importListSchema = z.object({
  items: z.array(importEntrySchema),
  next_cursor: z.string().nullable(),
});

// `ImportCreated` sérialise son champ sous l'alias `import` (mot réservé côté Python).
export const importCreatedSchema = z.object({
  import: importSchema,
  job_id: z.uuid(),
});

export type UploadResult = z.infer<typeof uploadResultSchema>;
export type ImportPreview = z.infer<typeof previewSchema>;
export type CountryCount = z.infer<typeof countryCountSchema>;
export type ImportReport = z.infer<typeof importReportSchema>;
export type Import = z.infer<typeof importSchema>;
export type ImportEntry = z.infer<typeof importEntrySchema>;

/**
 * « Date des données » de l'import (décision 2026-09-01 : le champ a disparu de l'écran, un
 * import date du jour). Convertit le jour `YYYY-MM-DD` calculé côté serveur en instant complet
 * avec fuseau, seul format que `ImportCreate.fetched_at` accepte.
 *
 * Repli sur l'instant courant — jamais une exception — quand la chaîne n'est pas un jour du
 * calendrier. `new Date(y, m, d)` **recale** silencieusement le 30 février au 2 mars : les
 * composants sont donc relus après construction. Sans cette garde, une chaîne inattendue
 * ferait lever `toISOString()` en pleine transition et le wizard entier partirait au
 * `error.tsx`, fichier et aperçu perdus.
 */
/**
 * Jour LOCAL de l'utilisateur (YYYY-MM-DD) — jamais le jour UTC : un import lancé à 1 h à
 * Paris doit dater ses métriques d'aujourd'hui, pas d'hier (review wizard-step3, minor 1).
 */
export function localDay(now: Date = new Date()): string {
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}

export function fetchedAtIso(day: string): string {
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
  if (match === null) return new Date().toISOString();
  const [year, month, date] = [Number(match[1]), Number(match[2]), Number(match[3])];
  const parsed = new Date(year, month - 1, date);
  if (
    parsed.getFullYear() !== year ||
    parsed.getMonth() !== month - 1 ||
    parsed.getDate() !== date
  ) {
    return new Date().toISOString();
  }
  return parsed.toISOString();
}

/**
 * Rapport d'un job vu comme rapport d'import. `job.report` est du jsonb libre côté socle :
 * un rapport d'une autre forme (job d'un autre module) rend `null` plutôt que de lever en
 * plein rendu.
 */
export function toImportReport(report: Record<string, unknown> | null): ImportReport | null {
  if (report === null) return null;
  const parsed = importReportSchema.safeParse(report);
  return parsed.success ? parsed.data : null;
}

/**
 * « (hors cible 152, pays non reconnu 3) » — le détail des lignes qu'a écartées le cloisonnement
 * par pays, accolé au total des lignes ignorées.
 *
 * Chaîne vide quand aucun des deux motifs n'est renseigné (rapport antérieur au §4.5) **et**
 * quand ils valent zéro : un export Semrush Magic ne porte aucune colonne pays, le filtre y est
 * un no-op, et chaque ligne du listing afficherait sinon « 1 ignorées (hors cible 0, pays
 * inconnu 0) » — du bruit qui masque le seul chiffre qui compte.
 */
function ignoredBreakdown(report: ImportReport): string {
  const parts: string[] = [];
  const offTarget = report.rows_off_target;
  const unresolved = report.rows_country_unresolved;
  if (typeof offTarget === "number" && offTarget > 0) parts.push(`hors cible ${offTarget}`);
  if (typeof unresolved === "number" && unresolved > 0)
    parts.push(`pays non reconnu ${unresolved}`);
  return parts.length > 0 ? ` (${parts.join(", ")})` : "";
}

/**
 * Résumé d'une ligne de table : « 1 200 lues · 1 180 insérées · 20 ignorées (hors cible 18,
 * pays non reconnu 2) · 0 en erreur ».
 *
 * Vit dans `lib/` et non dans le composant de rapport : la ligne de la table des imports,
 * la vue de détail **et** le toast global de fin de travail affichent tous le même résumé,
 * et le toast est produit par une Server Action qui n'a rien à importer d'un composant.
 */
export function reportSummary(report: ImportReport | null): string {
  if (report === null) return "—";
  const parts: string[] = [];
  if (typeof report.rows_read === "number") parts.push(`${report.rows_read} lues`);
  if (typeof report.rows_inserted === "number") parts.push(`${report.rows_inserted} insérées`);
  if (typeof report.rows_ignored === "number") {
    parts.push(`${report.rows_ignored} ignorées${ignoredBreakdown(report)}`);
  }
  if (typeof report.rows_error === "number") parts.push(`${report.rows_error} en erreur`);
  return parts.length > 0 ? parts.join(" · ") : "—";
}

/**
 * Deux mappings de colonnes désignent-ils le même câblage ?
 *
 * Sert à savoir si l'aperçu affiché correspond encore aux `Select` de l'étape 2 : tant qu'il
 * y correspond, « Appliquer le mapping » n'a rien à appliquer et s'efface. Comparaison par
 * champ, insensible à l'ordre d'insertion des clés — `{keyword, volume}` et `{volume, keyword}`
 * produisent le même import.
 */
export function mappingEquals(
  a: Record<string, string> | null | undefined,
  b: Record<string, string> | null | undefined,
): boolean {
  const left = a ?? {};
  const right = b ?? {};
  const keys = Object.keys(left);
  return keys.length === Object.keys(right).length && keys.every((key) => left[key] === right[key]);
}

/**
 * Nom proposé pour la liste de destination (« Semrush — 2026-08-31 », §1.5). Construit du
 * libellé court de l'adaptateur et de la date des données, jamais du nom de fichier.
 */
export function suggestedListName(format: string, fetchedAt: string): string {
  const label = formatLabel(format).split(" — ")[0] ?? format;
  return `${label} — ${fetchedAt}`;
}
