/**
 * Upload direct téléphone → Nextcloud familial.
 *
 * Ce module est utilisé par l'app mobile (jamais les octets ne passent par le
 * backend central) ET par les tests d'intégration serveur.
 * - < 10 Mo : PUT simple
 * - >= 10 Mo : chunking v2 (MKCOL uploads/ → PUT parts → MOVE .file)
 * - mtime fixé via X-OC-Mtime (survit à la reconstruction de l'index)
 * - idempotence : HEAD sur la destination avant de renvoyer
 *
 * La source des octets est un `UploadData` (voir plus bas) : côté mobile c'est
 * un adaptateur sur `expo-file-system` `File` (streaming depuis le disque,
 * plages lues via FileHandle), côté serveur/tests un Blob normalisé par
 * `blobUploadData`.
 */

const CHUNK_THRESHOLD_BYTES = 10 * 1024 * 1024;
const PART_SIZE_BYTES = 10 * 1024 * 1024;

/**
 * Source de données d'un upload.
 * Deux implémentations :
 * - `blobUploadData(blob)` — Blob/Uint8Array (tests serveur, Node) ;
 * - côté mobile, un adaptateur sur `expo-file-system` `File` : le PUT simple
 *   streame depuis le disque (`asBody` rend le File directement), le chunking
 *   lit les plages via `FileHandle.readBytes` (10 Mo par part, jamais le
 *   fichier entier en mémoire).
 */
export interface UploadData {
  /** Taille totale en octets. */
  size: number;
  /** Corps à passer à fetch pour le PUT simple — streamé depuis le disque quand possible. */
  asBody(): BodyInit;
  /**
   * Lit une plage [start, end) — utilisée par le chunking. Retourne un
   * BodyInit (Blob côté serveur, Uint8Array côté mobile) : pas de `new Blob`
   * sur le mobile — le polyfill RN rejette les ArrayBufferView
   * (« Creating blobs from 'ArrayBuffer' and 'ArrayBufferView' are not
   * supported »), et le fetch (WinterCG) accepte le Uint8Array tel quel.
   */
  readRange(start: number, end: number): Promise<BodyInit>;
  /** Libère les ressources (FileHandle) — appelé après l'upload, même en erreur. */
  close?(): void;
}

/** Normalise un Blob/Uint8Array en UploadData (serveur, tests, web). */
export function blobUploadData(data: Blob | Uint8Array): UploadData {
  // TS 5.7+/6 : Uint8Array<ArrayBufferLike> n'est pas assignable à BlobPart
  // (l'Union couvre SharedArrayBuffer) ; à l'exécution c'est un ArrayBuffer standard.
  const blob = data instanceof Blob ? data : new Blob([data as unknown as BlobPart]);
  return {
    size: blob.size,
    asBody: () => blob,
    // slice() sur un Blob est O(1) : pas de copie des octets.
    readRange: async (start, end) => blob.slice(start, end),
  };
}

export interface NextcloudUploadOptions {
  baseUrl: string;
  loginName: string;
  appPassword: string;
  rootFolder: string;
  /** Nom du fichier destination (déjà au format {YYYYMMDD-HHmmss}_{id}.{ext}) */
  fileName: string;
  /** Source des octets : adaptateur UploadData, ou Blob/Uint8Array (normalisé). */
  data: UploadData | Blob | Uint8Array;
  /** Date de capture — envoyée en X-OC-Mtime (timestamp unix) */
  capturedAt: Date;
  timeoutMs?: number;
}

export class NextcloudUploadError extends Error {
  constructor(
    message: string,
    public readonly httpStatus: number,
  ) {
    super(message);
    this.name = "NextcloudUploadError";
  }
}

export async function fileExistsInNextcloud(
  opts: Pick<NextcloudUploadOptions, "baseUrl" | "loginName" | "appPassword" | "rootFolder" | "fileName" | "capturedAt" | "timeoutMs">,
): Promise<boolean> {
  const { status } = await davFetch(opts, "HEAD", davPath(opts), {}, opts.timeoutMs);
  return status === 200;
}

export async function uploadToNextcloud(opts: NextcloudUploadOptions): Promise<void> {
  const data =
    opts.data instanceof Blob || opts.data instanceof Uint8Array
      ? blobUploadData(opts.data)
      : opts.data;
  const size = data.size;
  const auth = authHeader(opts);
  const target = davPath(opts);
  const base = opts.baseUrl.replace(/\/+$/, "");
  const mtime = Math.floor(opts.capturedAt.getTime() / 1000);
  const timeoutMs = opts.timeoutMs ?? 60_000;

  try {
    if (size < CHUNK_THRESHOLD_BYTES) {
      await ensureFolders(opts);
      const res = await fetch(target, {
        method: "PUT",
        headers: {
          Authorization: auth,
          "X-OC-Mtime": String(mtime),
          "Content-Type": "application/octet-stream",
        },
        body: data.asBody(),
        signal: timeoutSignal(timeoutMs),
      });
      if (res.status >= 400) {
        throw new NextcloudUploadError(`PUT a échoué (HTTP ${res.status})`, res.status);
      }
      return;
    }

    // --- Chunking v2 ---
    const uploadId = randomUuid();
    const uploadsRoot = `${base}/remote.php/dav/uploads/${encodeURIComponent(opts.loginName)}/${uploadId}`;

    const mkcol = await fetch(uploadsRoot, {
      method: "MKCOL",
      headers: { Authorization: auth },
      signal: timeoutSignal(timeoutMs),
    });
    if (mkcol.status >= 400 && mkcol.status !== 405) {
      throw new NextcloudUploadError(`MKCOL upload a échoué (HTTP ${mkcol.status})`, mkcol.status);
    }

    await ensureFolders(opts);

    const parts = Math.ceil(size / PART_SIZE_BYTES);
    for (let i = 0; i < parts; i++) {
      const start = i * PART_SIZE_BYTES;
      const end = Math.min(start + PART_SIZE_BYTES, size);
      const part = await data.readRange(start, end);
      const res = await fetch(`${uploadsRoot}/${String(i + 1).padStart(5, "0")}`, {
        method: "PUT",
        headers: {
          Authorization: auth,
          "Content-Type": "application/octet-stream",
          // Taille de la plage (end - start), pas part.size : le Uint8Array
          // mobile n'a pas de .size — et c'est la même valeur (slice borné).
          "OC-Chunk-Size": String(end - start),
        },
        body: part,
        signal: timeoutSignal(timeoutMs),
      });
      if (res.status >= 400) {
        throw new NextcloudUploadError(`Chunk ${i + 1}/${parts} a échoué (HTTP ${res.status})`, res.status);
      }
    }

    const destination = `${base}/remote.php/dav/files/${encodeURIComponent(opts.loginName)}/${folderSegments(opts).join("/")}/${encodeURIComponent(opts.fileName)}`;
    const move = await fetch(`${uploadsRoot}/.file`, {
      method: "MOVE",
      headers: {
        Authorization: auth,
        Destination: destination,
        "OC-Total-Length": String(size),
        "X-OC-Mtime": String(mtime),
      },
      signal: timeoutSignal(timeoutMs),
    });
    if (move.status >= 400) {
      throw new NextcloudUploadError(`MOVE final a échoué (HTTP ${move.status})`, move.status);
    }
  } finally {
    data.close?.();
  }
}

/* ------------------------------------------------------------------ */

/**
 * Encode base64 — correct en UTF-8. `btoa` n'existe pas sous Hermès (ni dans
 * RN ni dans le winter runtime Expo) et, même présent, il échoue sur les
 * caractères > U+00FF. `TextEncoder` est fourni par le winter runtime.
 */
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

export function base64Encode(input: string): string {
  const bytes = new TextEncoder().encode(input);
  let out = "";
  for (let i = 0; i < bytes.length; i += 3) {
    const a = bytes[i]!;
    const b = bytes[i + 1];
    const c = bytes[i + 2];
    const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0);
    out +=
      BASE64_ALPHABET[(n >> 18) & 63]! +
      BASE64_ALPHABET[(n >> 12) & 63]! +
      (b === undefined ? "=" : BASE64_ALPHABET[(n >> 6) & 63]!) +
      (c === undefined ? "=" : BASE64_ALPHABET[n & 63]!);
  }
  return out;
}

/** UUID v4 — fallback maison pour RN/Hermès (crypto.randomUUID absent). */
function randomUuid(): string {
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
    return crypto.randomUUID();
  }
  const bytes = new Uint8Array(16);
  if (typeof crypto !== "undefined" && crypto.getRandomValues) {
    crypto.getRandomValues(bytes);
  } else {
    for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
  }
  bytes[6] = (bytes[6]! & 0x0f) | 0x40;
  bytes[8] = (bytes[8]! & 0x3f) | 0x80;
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

/** Identifiant court (8 caractères alphanumériques) pour les noms de fichiers. */
export function shortId(length = 8): string {
  const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
  let out = "";
  const values = new Uint8Array(length);
  if (typeof crypto !== "undefined" && crypto.getRandomValues) {
    crypto.getRandomValues(values);
  } else {
    for (let i = 0; i < length; i++) values[i] = Math.floor(Math.random() * 256);
  }
  for (let i = 0; i < length; i++) {
    out += alphabet[values[i]! % alphabet.length];
  }
  return out;
}

/**
 * Nom de fichier canonique : {YYYYMMDD-HHmmss}_{shortuuid}.{ext}
 * — la date de capture survit même si l'index est reconstruit de zéro.
 * — le shortuuid sert de clé d'idempotence pour la reprise d'upload.
 */
export function buildMediaFileName(capturedAt: Date, extension: string): string {
  const p = (n: number, l = 2) => String(n).padStart(l, "0");
  const d = capturedAt;
  const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
  const ext = extension.replace(/^\./, "").toLowerCase() || "jpg";
  return `${stamp}_${shortId()}.${ext}`;
}

function authHeader(opts: Pick<NextcloudUploadOptions, "loginName" | "appPassword">): string {
  return `Basic ${base64Encode(`${opts.loginName}:${opts.appPassword}`)}`;
}

/**
 * Signal d'abandon avec délai. N'assigne rien sur les globaux : certains
 * runtimes (Hermès) n'ont pas AbortSignal.timeout et refusent de l'ajouter.
 */
function timeoutSignal(ms: number): AbortSignal {
  if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
    return AbortSignal.timeout(ms);
  }
  const ctrl = new AbortController();
  setTimeout(() => ctrl.abort(new Error(`Timed out after ${ms} ms`)), ms);
  return ctrl.signal;
}

/**
 * Crée les dossiers parents {root}/{YYYY}/{MM} si absents (MKCOL idempotent).
 */
async function ensureFolders(opts: {
  baseUrl: string;
  loginName: string;
  appPassword: string;
  rootFolder: string;
  capturedAt: Date;
}): Promise<void> {
  const base = opts.baseUrl.replace(/\/+$/, "");
  const auth = authHeader(opts);
  let path = `/remote.php/dav/files/${encodeURIComponent(opts.loginName)}`;
  for (const seg of folderSegments(opts)) {
    path += `/${encodeURIComponent(seg)}`;
    const res = await fetch(`${base}${path}`, {
      method: "MKCOL",
      headers: { Authorization: auth },
      signal: timeoutSignal(15_000),
    });
    if (res.status >= 400 && res.status !== 405 && res.status !== 301) {
      throw new NextcloudUploadError(`MKCOL ${seg} a échoué (HTTP ${res.status})`, res.status);
    }
  }
}

function folderSegments(
  opts: Pick<NextcloudUploadOptions, "rootFolder" | "capturedAt">,
): string[] {
  const d = opts.capturedAt;
  const root = opts.rootFolder.replace(/^\/+|\/+$/g, "");
  return [root, String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, "0")];
}

export function davPath(opts: {
  baseUrl: string;
  loginName: string;
  rootFolder: string;
  fileName: string;
  capturedAt: Date;
}): string {
  const base = opts.baseUrl.replace(/\/+$/, "");
  const segments = folderSegments(opts)
    .map((s) => encodeURIComponent(s))
    .join("/");
  return `${base}/remote.php/dav/files/${encodeURIComponent(opts.loginName)}/${segments}/${encodeURIComponent(opts.fileName)}`;
}

async function davFetch(
  opts: {
    baseUrl: string;
    loginName: string;
    appPassword: string;
    rootFolder: string;
    fileName: string;
    capturedAt: Date;
  },
  method: string,
  url: string,
  headers: Record<string, string>,
  timeoutMs?: number,
): Promise<Response> {
  return fetch(url, {
    method,
    headers: { Authorization: authHeader(opts), ...headers },
    signal: timeoutSignal(timeoutMs ?? 15_000),
  });
}
