"use client";

import { useEffect, useId, useRef, useState } from "react";
import { UploadCloudIcon } from "lucide-react";
import { env } from "@/env";
import { Spinner } from "@/components/app/spinner";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { problemMessage } from "@/lib/problem";
import { cn } from "@/lib/utils";

// **Seule** dérogation à « appels api côté serveur uniquement » (spec Imports, décision n°8) :
// le fichier part directement du navigateur vers FastAPI. C'est ce qui contourne à la fois le
// plafond de 4,5 Mo des route handlers Vercel et la double traversée de bande passante — un
// export Positions de 50 000 lignes dépasse couramment ce plafond.
//
// Le composant est générique : il ne connaît ni les imports, ni la forme de la réponse. Il
// dépose un ou plusieurs fichiers sur un chemin api, rend la progression d'envoi, et remet
// chaque réponse brute à l'appelant qui la parse avec son propre schéma. Crawl et GSC le
// réutiliseront tel quel.
//
// Multi-fichiers (SEOINSIGHT-57) : les envois sont **bornés à deux en parallèle** — assez pour
// couvrir la latence d'un aller-retour sans saturer le lien montant de l'utilisateur, ni la
// limite de connexions simultanées du navigateur. Chaque fichier porte sa progression, son
// bouton d'annulation et son erreur : un échec n'emporte jamais les autres.

/** Endpoint même-origine du plugin `jwt` de Better Auth (session requise, jeton 15 min). */
const TOKEN_ENDPOINT = "/api/auth/token";

/** Envois simultanés. Au-delà, ils se partagent la même bande passante sans rien accélérer. */
const MAX_PARALLEL = 2;

async function fetchJwt(): Promise<string> {
  const res = await fetch(TOKEN_ENDPOINT, { credentials: "include" });
  if (!res.ok) throw new Error("Session expirée : rechargez la page pour vous reconnecter.");
  const body = (await res.json()) as { token?: unknown };
  if (typeof body.token !== "string") throw new Error("Jeton d'authentification indisponible.");
  return body.token;
}

/** Envoi abandonné (bouton « Annuler » ou démontage) : à distinguer d'un vrai échec. */
class UploadAborted extends Error {}

function post(
  url: string,
  token: string,
  form: FormData,
  onProgress: (ratio: number | null) => void,
  register: (request: XMLHttpRequest | null) => void,
): Promise<unknown> {
  // `XMLHttpRequest` et non `fetch` : lui seul expose la progression **d'envoi**, qui est la
  // seule information disponible tant que le fichier n'est pas arrivé. Aucune dépendance.
  return new Promise((resolve, reject) => {
    const request = new XMLHttpRequest();
    request.open("POST", url);
    request.setRequestHeader("Authorization", `Bearer ${token}`);
    request.setRequestHeader("Accept", "application/json");
    request.upload.onprogress = (event) => {
      onProgress(event.lengthComputable ? event.loaded / event.total : null);
    };
    request.onerror = () =>
      reject(
        new Error(
          "Impossible de joindre l'API. Vérifiez qu'elle est démarrée et joignable depuis ce navigateur.",
        ),
      );
    // Sans ces deux gestionnaires, un `abort()` (bouton « Annuler », démontage du composant)
    // laisserait la promesse **jamais réglée** : l'écran resterait « Envoi… » pour toujours.
    request.onabort = () => reject(new UploadAborted());
    request.ontimeout = () => reject(new Error("L'envoi a expiré."));
    request.onload = () => {
      if (request.status >= 200 && request.status < 300) {
        try {
          resolve(JSON.parse(request.responseText) as unknown);
        } catch {
          reject(new Error("Réponse illisible de l'API."));
        }
        return;
      }
      reject(new Error(problemMessage(request.status, request.responseText)));
    };
    register(request);
    request.send(form);
  });
}

/** Un fichier de la file : en attente, en vol, ou en échec. Les envois réussis en sortent. */
type UploadTask = {
  id: string;
  file: File;
  status: "queued" | "sending" | "error";
  ratio: number | null;
  error: string | null;
};

type UploadDropzoneProps = {
  /** Chemin api appelé directement (le seul autorisé côté CORS : `/imports/files`). */
  path: string;
  /** Nom du champ multipart portant le fichier. */
  fileField?: string;
  /** Champs multipart supplémentaires (`site_target_id`, par exemple). */
  fields?: Record<string, string>;
  accept?: string;
  /** Plusieurs fichiers d'un coup (glisser-déposer groupé, sélection multiple). */
  multiple?: boolean;
  /** Plafond côté navigateur : refuser tout de suite plutôt qu'après 256 Mio envoyés. */
  maxBytes?: number;
  label: string;
  hint?: string;
  disabled?: boolean;
  /** Réponse brute de l'api — l'appelant la parse avec son propre schéma Zod. */
  onUploaded: (payload: unknown, file: File) => void;
};

export function UploadDropzone({
  path,
  fileField = "file",
  fields,
  accept = ".csv,text/csv",
  multiple = false,
  maxBytes,
  label,
  hint,
  disabled = false,
  onUploaded,
}: UploadDropzoneProps) {
  const inputId = useId();
  const [tasks, setTasks] = useState<UploadTask[]>([]);
  const [dragging, setDragging] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
  // Requêtes en vol, par tâche : c'est ce qui rend l'annulation possible fichier par fichier.
  const requests = useRef(new Map<string, XMLHttpRequest>());
  //  Tâches abandonnées qui n'ont pas (encore) de requête à interrompre. L'envoi commence par
  //  un aller-retour `fetchJwt` : « Annuler » pendant cette fenêtre n'avait rien à `abort()`,
  //  la ligne disparaissait, et le fichier finissait quand même par atterrir dans la liste de
  //  l'appelant. Le drapeau est relu de part et d'autre de l'attente.
  const abandoned = useRef(new Set<string>());
  const waiting = useRef<UploadTask[]>([]);
  const running = useRef(0);
  const sequence = useRef(0);
  // La réponse arrive après plusieurs rendus : sans ce relais, l'envoi remettrait sa charge
  // utile à la fonction d'un rendu périmé (une cible changée entre-temps, par exemple).
  const handler = useRef(onUploaded);
  useEffect(() => {
    handler.current = onUploaded;
  });

  // Un envoi de 256 Mio ne doit pas survivre à la page qui l'a lancé.
  useEffect(() => {
    const inflight = requests.current;
    return () => {
      for (const request of inflight.values()) request.abort();
    };
  }, []);

  const patch = (id: string, next: Partial<UploadTask>) => {
    setTasks((previous) => previous.map((task) => (task.id === id ? { ...task, ...next } : task)));
  };
  const forget = (id: string) => {
    setTasks((previous) => previous.filter((task) => task.id !== id));
  };

  const send = async (task: UploadTask) => {
    patch(task.id, { status: "sending", ratio: 0, error: null });
    try {
      if (abandoned.current.has(task.id)) throw new UploadAborted();
      const token = await fetchJwt();
      // Relu APRÈS le jeton : c'est exactement la fenêtre où « Annuler » n'avait aucune
      // requête à interrompre.
      if (abandoned.current.has(task.id)) throw new UploadAborted();
      const form = new FormData();
      form.append(fileField, task.file);
      for (const [key, value] of Object.entries(fields ?? {})) form.append(key, value);
      const payload = await post(
        `${env.NEXT_PUBLIC_API_URL}${path}`,
        token,
        form,
        (ratio) => patch(task.id, { ratio }),
        (request) => {
          if (request === null) {
            requests.current.delete(task.id);
            return;
          }
          requests.current.set(task.id, request);
          // Dernier créneau : un abandon décidé entre les deux `await` ci-dessus arriverait
          // après la vérification et avant que la requête ne soit interruptible.
          if (abandoned.current.has(task.id)) request.abort();
        },
      );
      forget(task.id);
      handler.current(payload, task.file);
    } catch (cause) {
      // Un abandon volontaire n'est pas une erreur : la ligne disparaît, rien en rouge.
      if (cause instanceof UploadAborted) {
        forget(task.id);
        return;
      }
      patch(task.id, {
        status: "error",
        ratio: null,
        error: cause instanceof Error ? cause.message : "L'envoi a échoué.",
      });
    } finally {
      requests.current.delete(task.id);
      abandoned.current.delete(task.id);
      // Le même fichier redéposé après une erreur doit relancer un `change`.
      if (inputRef.current) inputRef.current.value = "";
    }
  };

  /** Démarre autant d'envois que le plafond de parallélisme l'autorise, puis s'arrête. */
  const pump = () => {
    while (running.current < MAX_PARALLEL) {
      const next = waiting.current.shift();
      if (next === undefined) return;
      running.current += 1;
      void send(next).finally(() => {
        running.current -= 1;
        pump();
      });
    }
  };

  const enqueue = (chosen: readonly File[]) => {
    const accepted: UploadTask[] = [];
    for (const file of chosen) {
      sequence.current += 1;
      const id = `upload-${sequence.current}`;
      if (maxBytes !== undefined && file.size > maxBytes) {
        accepted.push({
          id,
          file,
          status: "error",
          ratio: null,
          error: `Fichier trop volumineux : le plafond est de ${Math.floor(maxBytes / 1024 / 1024)} Mio.`,
        });
        continue;
      }
      accepted.push({ id, file, status: "queued", ratio: null, error: null });
    }
    if (accepted.length === 0) return;
    setTasks((previous) => [...previous, ...accepted]);
    waiting.current.push(...accepted.filter((task) => task.status === "queued"));
    pump();
  };

  const cancel = (task: UploadTask) => {
    // Posé d'abord, et dans tous les cas : la tâche peut être entre son jeton et sa requête,
    // auquel cas c'est ce drapeau — et lui seul — qui l'empêchera de partir.
    abandoned.current.add(task.id);
    const request = requests.current.get(task.id);
    if (request !== undefined) {
      // `onabort` fera disparaître la ligne : l'ôter ici la ferait clignoter deux fois.
      request.abort();
      return;
    }
    waiting.current = waiting.current.filter((queued) => queued.id !== task.id);
    forget(task.id);
    // Une tâche qui n'est pas encore entrée dans `send` (en file, ou refusée sur sa taille)
    // n'y entrera jamais : le drapeau n'a personne à prévenir et n'a pas à survivre.
    if (task.status !== "sending") abandoned.current.delete(task.id);
  };

  const retry = (task: UploadTask) => {
    forget(task.id);
    enqueue([task.file]);
  };

  // En mode simple, la zone se ferme pendant l'envoi : un second fichier remplacerait le
  // premier sans que rien ne le dise. En mode multiple, elle reste ouverte — c'est tout
  // l'intérêt, on empile.
  const blocked = disabled || (!multiple && tasks.length > 0);

  const take = (list: FileList | null) => {
    if (list === null) return;
    const chosen = Array.from(list);
    enqueue(multiple ? chosen : chosen.slice(0, 1));
  };

  return (
    <div className="space-y-3">
      {/* Le glisser-déposer vit sur le conteneur, jamais sur le <label> : c'est un raccourci
          souris. Le chemin accessible reste le <label> et l'input qu'il porte, activables au
          clavier comme n'importe quel champ fichier. */}
      <div
        onDragOver={(event) => {
          event.preventDefault();
          if (!blocked) setDragging(true);
        }}
        onDragLeave={() => setDragging(false)}
        onDrop={(event) => {
          event.preventDefault();
          setDragging(false);
          if (blocked) return;
          take(event.dataTransfer.files);
        }}
        className={cn(
          "rounded-lg border border-dashed",
          dragging && "border-primary bg-muted/50",
          blocked && "opacity-60",
        )}
      >
        <label
          htmlFor={inputId}
          className={cn(
            "flex cursor-pointer flex-col items-center gap-2 p-8 text-center text-sm",
            blocked && "pointer-events-none",
          )}
        >
          <UploadCloudIcon className="text-muted-foreground size-6" />
          <span className="font-medium">{label}</span>
          {hint ? <span className="text-muted-foreground text-xs">{hint}</span> : null}
          <input
            id={inputId}
            ref={inputRef}
            type="file"
            accept={accept}
            multiple={multiple}
            disabled={blocked}
            className="sr-only"
            onChange={(event) => take(event.target.files)}
          />
        </label>
      </div>

      {tasks.length > 0 ? (
        <ul className="space-y-2">
          {tasks.map((task) => (
            <li key={task.id} className="space-y-1">
              <div className="flex flex-wrap items-center justify-between gap-2">
                {/* La barre dit le pourcentage exact, le spinner dit que ça avance : un envoi
                    de 256 Mio passe de longues secondes sur le même palier. */}
                <div className="text-muted-foreground flex min-w-0 items-center gap-2 text-sm">
                  {task.status === "error" ? null : <Spinner size="sm" />}
                  <span className="min-w-0 truncate">
                    {task.status === "error" ? task.file.name : `Envoi de ${task.file.name}`}
                    {task.status === "queued" ? " — en attente" : ""}
                    {task.status === "sending"
                      ? task.ratio === null
                        ? "…"
                        : ` — ${Math.round(task.ratio * 100)} %`
                      : ""}
                  </span>
                </div>
                <div className="flex shrink-0 items-center gap-1">
                  {task.status === "error" ? (
                    <Button type="button" variant="ghost" size="sm" onClick={() => retry(task)}>
                      Réessayer
                    </Button>
                  ) : null}
                  <Button type="button" variant="ghost" size="sm" onClick={() => cancel(task)}>
                    {task.status === "error" ? "Retirer" : "Annuler"}
                  </Button>
                </div>
              </div>
              {task.status === "error" ? (
                <p role="alert" className="text-destructive text-sm">
                  {task.error}
                </p>
              ) : (
                <Progress value={task.ratio === null ? null : Math.round(task.ratio * 100)} />
              )}
            </li>
          ))}
        </ul>
      ) : null}
    </div>
  );
}
