import * as Network from "expo-network";
import apiClient, { ApiClientError, isApiClientError } from "./apiClient";
import type { ApiEnvelope } from "../types/auth";
import type {
  MobileIntervention,
  MobileStep,
} from "../types/intervention";
import type { SyncQueueItem } from "../types/offline";
import { requireEnvironmentBaseUrl } from "../core/environmentService";
import { getMobileConfigForBaseUrl } from "../core/mobileConfigService";
import {
  applyServerStepsDelta,
  countDeadQueueItems,
  enqueueSyncAction,
  getLastSyncTimestamp,
  getPendingQueueItems,
  markQueueItemDead,
  markQueueItemDone,
  markQueueItemFailed,
  markQueueItemWalled,
  mergeInterventionsSnapshot,
  setLastSyncTimestamp,
} from "../core/localDatabase";
import { readSession } from "./tokenStorage";
import { flushCrashReports } from "../core/crashLog";
import { resolveMediaFileUri } from "../core/fileStorage";

type DeltaPayload = {
  items?: MobileIntervention[];
  interventions?: MobileIntervention[];
  intervention_ids?: number[];
  steps?: MobileStep[];
  since?: string;
  server_time?: string;
};

type MultipartQueueFile = {
  field: string;
  uri: string;
  name: string;
  type: string;
};

type MultipartQueuePayload = Record<string, unknown> & {
  __multipart: boolean;
  upload_files: MultipartQueueFile[];
};

function isMultipartQueuePayload(payload: Record<string, unknown>): payload is MultipartQueuePayload {
  if (payload.__multipart !== true) {
    return false;
  }

  if (!Array.isArray(payload.upload_files)) {
    return false;
  }

  return payload.upload_files.every((file) => {
    if (!file || typeof file !== "object") {
      return false;
    }
    const candidate = file as Record<string, unknown>;
    return (
      typeof candidate.field === "string" &&
      typeof candidate.uri === "string" &&
      typeof candidate.name === "string" &&
      typeof candidate.type === "string"
    );
  });
}

async function postMultipartFromQueue(
  endpoint: string,
  payload: MultipartQueuePayload
): Promise<void> {
  const baseUrl = await requireEnvironmentBaseUrl();
  const session = await readSession();
  if (!session.accessToken) {
    throw new ApiClientError("Unauthorized", 401);
  }

  const config = await getMobileConfigForBaseUrl(baseUrl);
  const tenantId = config?.config.tenant_id;

  const formData = new FormData();
  Object.entries(payload).forEach(([key, value]) => {
    if (key === "__multipart" || key === "upload_files" || value === undefined) {
      return;
    }

    if (value === null) {
      formData.append(key, "");
      return;
    }

    if (
      typeof value === "string" ||
      typeof value === "number" ||
      typeof value === "boolean"
    ) {
      formData.append(key, String(value));
      return;
    }

    formData.append(key, JSON.stringify(value));
  });

  payload.upload_files.forEach((file) => {
    // M2 : re-resout l'URI sur le conteneur COURANT (le GUID iOS change entre
    // builds -> l'URI absolue figee en file devient invalide alors que le
    // fichier existe toujours sous le meme nom).
    formData.append(file.field || "files[]", {
      uri: resolveMediaFileUri(file.uri),
      name: file.name,
      type: file.type,
    } as unknown as Blob);
  });

  const headers: Record<string, string> = {
    Authorization: `Bearer ${session.accessToken}`,
  };
  if (tenantId !== undefined && tenantId !== null) {
    headers["X-Tenant-Id"] = String(tenantId);
  }

  const url = endpoint.startsWith("http") ? endpoint : `${baseUrl}${endpoint}`;
  const response = await fetch(url, {
    method: "POST",
    headers,
    body: formData,
  });

  if (!response.ok) {
    let message = `HTTP ${response.status}`;
    let code: string | undefined;
    try {
      const data = (await response.json()) as { message?: string; code?: string };
      if (data.message) {
        message = data.message;
      }
      // On capte aussi le code metier (ex: "BILLING_WALL") pour que la sync
      // puisse classer correctement un upload photo refuse par le mur.
      if (typeof data.code === "string" && data.code) {
        code = data.code;
      }
    } catch {
      // ignore json parse failure
    }
    throw new ApiClientError(message, response.status, code);
  }
}

// Mur de facturation (cote serveur, Lot 1) : une ecriture de completion refusee
// parce que l'abonnement du tenant n'est plus actif revient en 402 portant ce
// code. A distinguer d'un 402 generique (retryable) : ici l'item NE DOIT PAS
// mourir, il se re-poussera tel quel a la reactivation.
const BILLING_WALL_CODE = "BILLING_WALL";

// Handler global "mur de facturation" : la sync etant decouplee de l'UI, App
// l'enregistre au montage pour afficher un message dedie (SANS logout : le mur
// n'est pas une session invalide). Invoque une seule fois par episode de mur ;
// re-arme des qu'un envoi repasse (le mur est leve apres reactivation).
let billingWallHandler: ((message: string) => void) | null = null;
let billingWallNotified = false;

export function registerBillingWallHandler(handler: ((message: string) => void) | null): void {
  billingWallHandler = handler;
}

type SyncErrorKind = "auth" | "contract" | "wall" | "retryable";

// Classe l'erreur d'un envoi de file :
//  - "auth" (401/403)            -> session invalide : on STOPPE la passe
//    (les items restent 'pending' et repartiront apres re-auth/refresh).
//  - "contract" (400/404/409/413/422) -> l'item est definitivement mauvais :
//    inutile de rejouer, on le marque 'dead' et on CONTINUE les suivants.
//    413 (Payload Too Large) inclus : une photo trop volumineuse ne passera
//    jamais, la rejouer 8x est inutile (M5). L'item dead est remonte a l'UI (M4).
//  - "wall" (402 BILLING_WALL)   -> mur de facturation : abonnement inactif.
//    L'ecriture est valide, on STOPPE la passe et on garde l'item 'pending'
//    SANS attempts++ (cf. markQueueItemWalled) -> re-pousse a la reactivation.
//  - "retryable" (5xx, reseau, erreur sans status) -> attempts++ (dead au
//    cap MAX_SYNC_ATTEMPTS) et on CONTINUE.
function classifySyncError(error: unknown): SyncErrorKind {
  if (isApiClientError(error) && typeof error.status === "number") {
    const status = error.status;
    if (status === 402 && error.code === BILLING_WALL_CODE) {
      return "wall";
    }
    if (status === 401 || status === 403) {
      return "auth";
    }
    if ([400, 404, 409, 413, 422].includes(status)) {
      return "contract";
    }
  }
  return "retryable";
}

function pickItems(payload: DeltaPayload): MobileIntervention[] {
  if (Array.isArray(payload.items)) return payload.items;
  if (Array.isArray(payload.interventions)) return payload.interventions;
  return [];
}

function toPrimitiveParams(input: Record<string, unknown>): Record<string, string | number | boolean> {
  const out: Record<string, string | number | boolean> = {};
  Object.entries(input).forEach(([key, value]) => {
    if (
      typeof value === "string" ||
      typeof value === "number" ||
      typeof value === "boolean"
    ) {
      out[key] = value;
    }
  });
  return out;
}

export async function isNetworkOnline(): Promise<boolean> {
  try {
    const state = await Network.getNetworkStateAsync();
    return Boolean(state.isConnected && state.isInternetReachable !== false);
  } catch {
    return true;
  }
}

export function subscribeNetworkChanges(onChange: (online: boolean) => void): () => void {
  const subscription = Network.addNetworkStateListener((state) => {
    onChange(Boolean(state.isConnected && state.isInternetReachable !== false));
  });

  return () => {
    subscription.remove();
  };
}

export async function enqueueOfflineAction(input: {
  endpoint: string;
  method: "GET" | "POST";
  payload: Record<string, unknown>;
  entityType: string;
  entityId: string;
}): Promise<void> {
  await enqueueSyncAction(input);
}

async function dispatchQueueItem(item: SyncQueueItem): Promise<void> {
  if (item.method === "POST") {
    if (isMultipartQueuePayload(item.payload)) {
      await postMultipartFromQueue(item.endpoint, item.payload);
      return;
    }
    await apiClient.post(item.endpoint, item.payload);
    return;
  }
  await apiClient.get(item.endpoint, { params: toPrimitiveParams(item.payload) });
}

// Une action workflow mise en file hors-ligne (save_step, complete_workflow)
// porte la `version` du workflow FIGEE au moment de la saisie. Le serveur
// applique un verrou optimiste (assertVersion -> 409 WORKFLOW_VERSION_STALE).
// Au rejeu sequentiel, le 1er save passe et le serveur incremente la version
// -> toutes les actions suivantes, avec leur version perimee, sont rejetees en
// 409 et l'item est jete (classe "contract"). Resultat : etapes ou FINALISATION
// remplies hors-ligne silencieusement perdues. La file d'un meme tech rejouee
// en sequence n'a aucune concurrence reelle : on REBASE donc chaque action sur
// la version courante (cf. dispatchVersionedWorkflowAction).
//
// IMPORTANT (M1) : complete_workflow DOIT etre inclus. Sinon, quand des
// save_step hors-ligne le precedent dans la file, ils incrementent la version
// serveur (via rebase) puis complete_workflow part avec sa version d'origine
// perimee -> 409 -> "contract" -> 'dead' -> la FINALISATION est perdue
// silencieusement (l'intervention reste "a faire", sans aucune alerte).
const VERSIONED_WORKFLOW_ACTIONS = new Set(["save_step", "complete_workflow"]);

function isVersionedWorkflowAction(
  payload: Record<string, unknown>
): payload is Record<string, unknown> & { intervention_id: number } {
  return (
    typeof payload?.action === "string" &&
    VERSIONED_WORKFLOW_ACTIONS.has(payload.action) &&
    Number(payload?.intervention_id) > 0
  );
}

// Lit la version courante du workflow d'une intervention (best-effort) pour
// rebaser un save_step rejoue. Renvoie null si indisponible (offline/erreur).
async function fetchCurrentWorkflowVersion(interventionId: number): Promise<number | null> {
  try {
    const resp = await apiClient.get<
      ApiEnvelope<{ workflow?: { version?: number } }>
    >("/mobile/intervention_steps.php", { params: { id: interventionId } });
    const v = Number(resp.data?.data?.workflow?.version);
    return Number.isFinite(v) && v > 0 ? v : null;
  } catch {
    return null;
  }
}

// Lit le statut courant du workflow (best-effort). Sert a confirmer qu'une
// finalisation rejouee a en fait DEJA reussi cote serveur (status 'completed').
async function fetchWorkflowStatus(interventionId: number): Promise<string | null> {
  try {
    const resp = await apiClient.get<
      ApiEnvelope<{ workflow?: { status?: string } }>
    >("/mobile/intervention_steps.php", { params: { id: interventionId } });
    const s = resp.data?.data?.workflow?.status;
    return typeof s === "string" ? s : null;
  } catch {
    return null;
  }
}

type WorkflowSavePost = ApiEnvelope<{ workflow?: { version?: number } }>;

// POST une action workflow (save_step / complete_workflow) en injectant
// `version`, et renvoie la nouvelle version du workflow (fallback version+1 :
// le serveur incremente de 1 a chaque mutation).
async function postWorkflowActionWithVersion(
  endpoint: string,
  payload: Record<string, unknown>,
  version: number
): Promise<number> {
  const resp = await apiClient.post<WorkflowSavePost>(endpoint, { ...payload, version });
  const next = Number(resp.data?.data?.workflow?.version);
  return Number.isFinite(next) && next > 0 ? next : version + 1;
}

// Rejoue une action workflow hors-ligne (save_step / complete_workflow) en
// rebasant sa version sur l'etat serveur courant. `versionByIntervention` suit
// la version a travers les actions successives d'une meme intervention dans la
// passe (seed via 1 GET, puis on chaine la version renvoyee par chaque POST).
// Backstop : un 409 residuel (drift via une autre ecriture, ex. upload photo)
// re-seed via GET et retente une fois avant d'abandonner a la classification
// standard.
async function dispatchVersionedWorkflowAction(
  item: SyncQueueItem,
  versionByIntervention: Map<number, number>
): Promise<void> {
  const payload = item.payload as Record<string, unknown> & { intervention_id: number };
  const interventionId = Number(payload.intervention_id);

  let version = versionByIntervention.get(interventionId);
  if (version === undefined) {
    const fromPayload = Number(payload.version);
    version =
      (await fetchCurrentWorkflowVersion(interventionId)) ??
      (Number.isFinite(fromPayload) ? fromPayload : 0);
  }

  try {
    const next = await postWorkflowActionWithVersion(item.endpoint, payload, version);
    versionByIntervention.set(interventionId, next);
  } catch (error) {
    if (isApiClientError(error) && error.status === 409) {
      // complete_workflow n'a PAS d'idempotency_key serveur. Si la reponse HTTP
      // d'une finalisation a ete perdue APRES le commit (coupure reseau), le
      // rejeu retombe sur un workflow deja 'completed' -> WORKFLOW_LOCKED (409).
      // L'action a donc REUSSI : on la considere comme done (idempotence), au lieu
      // de la marquer 'dead' a tort (ce qui declenchait une fausse alerte M4).
      if (payload.action === "complete_workflow") {
        const status = await fetchWorkflowStatus(interventionId);
        if (status === "completed") {
          return;
        }
      }
      const fresh = await fetchCurrentWorkflowVersion(interventionId);
      if (fresh !== null) {
        const next = await postWorkflowActionWithVersion(item.endpoint, payload, fresh);
        versionByIntervention.set(interventionId, next);
        return;
      }
    }
    throw error;
  }
}

// Traite l'echec d'un item de la passe et indique s'il faut poursuivre le
// drain ("continue") ou l'arreter ("break"). Extrait de processSyncQueue pour
// en sortir le branchement par type d'erreur (mur / contrat / auth / retry).
async function handleSyncFailure(
  item: SyncQueueItem,
  error: unknown
): Promise<"continue" | "break"> {
  const message = error instanceof Error ? error.message : "Sync failed";
  const kind = classifySyncError(error);

  if (kind === "contract") {
    // Item definitivement mauvais : on le sort de la file et on poursuit
    // les suivants (un seul 404 ne doit plus bloquer tout le drain).
    await markQueueItemDead(item.id, message);
    return "continue";
  }

  if (kind === "wall") {
    // Mur de facturation : abonnement tenant inactif. L'ecriture est valide,
    // on la GARDE 'pending' sans attempts++ (markQueueItemWalled) -> elle ne
    // meurt pas au cap et se re-poussera a la reactivation. Inutile
    // d'insister sur le reste de la passe (tout sera mure pareil) : on stoppe
    // et on previent l'UI une seule fois par episode.
    await markQueueItemWalled(item.id, message);
    if (!billingWallNotified) {
      billingWallNotified = true;
      billingWallHandler?.(message);
    }
    return "break";
  }

  if (kind === "auth") {
    // Session invalide : inutile d'insister sur le reste de la passe.
    await markQueueItemFailed(item.id, message);
    return "break";
  }

  // Retryable (5xx / reseau) : on incremente (dead au cap) et on continue.
  await markQueueItemFailed(item.id, message);
  return "continue";
}

export async function processSyncQueue(limit = 50): Promise<{ processed: number; failed: number }> {
  const items = await getPendingQueueItems(limit);
  let processed = 0;
  let failed = 0;
  // Suivi de la version workflow par intervention sur la passe : permet de
  // rebaser les save_step hors-ligne (version figee a la saisie) sur l'etat
  // serveur courant et d'eviter les 409 en cascade qui les feraient jeter.
  const versionByIntervention = new Map<number, number>();

  for (const item of items) {
    try {
      if (item.method === "POST" && isVersionedWorkflowAction(item.payload)) {
        await dispatchVersionedWorkflowAction(item, versionByIntervention);
      } else {
        await dispatchQueueItem(item);
      }
      await markQueueItemDone(item.id);
      processed += 1;
      // Un envoi a repasse -> le mur (s'il y en avait un) est leve : on re-arme
      // la notification pour le prochain episode eventuel.
      billingWallNotified = false;
    } catch (error) {
      failed += 1;
      if ((await handleSyncFailure(item, error)) === "break") {
        break;
      }
    }
  }

  return { processed, failed };
}

export async function pullSyncDelta(): Promise<number> {
  // Watermark au format MySQL 'Y-m-d H:i:s' (le serveur le renvoie via
  // server_time et le compare a interventions.updated_at, en tz serveur).
  // Defaut = epoch -> pull complet au premier demarrage / cache vide.
  const since = (await getLastSyncTimestamp()) || "1970-01-01 00:00:00";

  const response = await apiClient.get<ApiEnvelope<DeltaPayload>>("/mobile/sync_pull.php", {
    params: { since },
  });

  if (!response.data?.success || !response.data.data) {
    return 0;
  }

  const payload = response.data.data;
  const items = pickItems(payload);
  const keepIds = Array.isArray(payload.intervention_ids)
    ? payload.intervention_ids
        .map(Number)
        .filter((id) => Number.isFinite(id))
    : null;

  // Merge non destructif : upsert des interventions changees + purge des ids
  // disparus (keepIds = liste complete serveur). On NE remplace plus tout le
  // snapshot (l'ancien saveInterventionsSnapshot vidait la table -> un delta
  // partiel effacait toutes les interventions non modifiees).
  if (items.length > 0 || keepIds) {
    await mergeInterventionsSnapshot(items, keepIds);
  }

  const steps = Array.isArray(payload.steps) ? payload.steps : [];
  const appliedSteps = steps.length > 0 ? await applyServerStepsDelta(steps) : 0;

  // Watermark = horloge SERVEUR (server_time), jamais celle du device :
  // updated_at est en tz serveur, comparer avec l'heure locale du tel
  // introduirait un decalage (lignes ratees ou rejouees).
  if (typeof payload.server_time === "string" && payload.server_time) {
    await setLastSyncTimestamp(payload.server_time);
  }

  return items.length + appliedSteps;
}

// Garde de reentrance : la sync est declenchee par 3 sources (boot, retour
// reseau, retour foreground) qui peuvent coincider. Sans verrou, deux passes
// concurrentes liraient les memes items 'pending' et les enverraient en double
// (intervention_start/precheck n'ont pas de cle d'idempotence). Le flag
// module-level serialise : la 2e source recoit un resultat neutre et n'envoie
// rien tant que la 1ere n'a pas fini.
let syncInFlight = false;

export async function syncOnReconnect(): Promise<{
  pushed: number;
  failed: number;
  pulled: number;
  dead: number;
  skipped?: boolean;
}> {
  if (syncInFlight) {
    return { pushed: 0, failed: 0, pulled: 0, dead: 0, skipped: true };
  }
  syncInFlight = true;
  try {
    // M4 (corrige) : on alerte sur les NOUVELLES morts de CETTE passe, pas sur le
    // total cumulatif. countDeadQueueItems compte tous les 'dead' jamais purges ;
    // l'utiliser tel quel re-affichait l'alerte indefiniment a chaque sync/boot
    // (regression). On mesure donc le DELTA avant/apres la passe.
    const deadBefore = await countDeadQueueItems().catch(() => 0);

    const pushResult = await processSyncQueue();

    let pulled = 0;
    try {
      pulled = await pullSyncDelta();
    } catch {
      pulled = 0;
    }

    // Remontee best-effort des incidents au backend, sur le meme cycle que la
    // sync (boot / reconnexion / foreground). flushCrashReports ne lance jamais
    // et conserve les logs si l'endpoint n'est pas encore deploye (404).
    await flushCrashReports();

    const deadAfter = await countDeadQueueItems().catch(() => 0);
    const dead = Math.max(0, deadAfter - deadBefore);

    return {
      pushed: pushResult.processed,
      failed: pushResult.failed,
      pulled,
      dead,
    };
  } finally {
    syncInFlight = false;
  }
}
