jest.mock("../../src/services/apiClient", () => ({
  __esModule: true,
  default: { get: jest.fn(), post: jest.fn() },
  ApiClientError: class ApiClientError extends Error {
    status?: number;
    code?: string;
    constructor(message: string, status?: number, code?: string) {
      super(message);
      this.name = "ApiClientError";
      this.status = status;
      this.code = code;
    }
  },
  isApiClientError: (e: unknown) => e instanceof Error && e.name === "ApiClientError",
}));
jest.mock("../../src/core/environmentService", () => ({
  requireEnvironmentBaseUrl: jest.fn(),
}));
jest.mock("../../src/core/mobileConfigService", () => ({
  getMobileConfigForBaseUrl: jest.fn(),
}));
jest.mock("../../src/services/tokenStorage", () => ({
  readSession: jest.fn(),
}));
jest.mock("../../src/core/localDatabase", () => ({
  applyServerStepsDelta: jest.fn(),
  countDeadQueueItems: jest.fn().mockResolvedValue(0),
  enqueueSyncAction: jest.fn(),
  getLastSyncTimestamp: jest.fn(),
  getPendingQueueItems: jest.fn(),
  markQueueItemDone: jest.fn(),
  markQueueItemFailed: jest.fn(),
  markQueueItemDead: jest.fn(),
  markQueueItemWalled: jest.fn(),
  mergeInterventionsSnapshot: jest.fn(),
  setLastSyncTimestamp: jest.fn(),
}));

import * as Network from "expo-network";
import apiClient, { ApiClientError } from "../../src/services/apiClient";
import * as environmentService from "../../src/core/environmentService";
import * as mobileConfigService from "../../src/core/mobileConfigService";
import * as tokenStorage from "../../src/services/tokenStorage";
import * as localDatabase from "../../src/core/localDatabase";
import {
  enqueueOfflineAction,
  isNetworkOnline,
  processSyncQueue,
  pullSyncDelta,
  registerBillingWallHandler,
  subscribeNetworkChanges,
  syncOnReconnect,
} from "../../src/services/syncService";

const get = apiClient.get as jest.Mock;
const post = apiClient.post as jest.Mock;
const getNetworkStateAsync = Network.getNetworkStateAsync as jest.Mock;
const addNetworkStateListener = Network.addNetworkStateListener as jest.Mock;
const requireBase = environmentService.requireEnvironmentBaseUrl as jest.Mock;
const getConfig = mobileConfigService.getMobileConfigForBaseUrl as jest.Mock;
const readSession = tokenStorage.readSession as jest.Mock;
const enqueue = localDatabase.enqueueSyncAction as jest.Mock;
const getPending = localDatabase.getPendingQueueItems as jest.Mock;
const markDone = localDatabase.markQueueItemDone as jest.Mock;
const markFailed = localDatabase.markQueueItemFailed as jest.Mock;
const markDead = localDatabase.markQueueItemDead as jest.Mock;
const markWalled = localDatabase.markQueueItemWalled as jest.Mock;
const getLastSync = localDatabase.getLastSyncTimestamp as jest.Mock;
const setLastSync = localDatabase.setLastSyncTimestamp as jest.Mock;
const mergeSnap = localDatabase.mergeInterventionsSnapshot as jest.Mock;
const applyDelta = localDatabase.applyServerStepsDelta as jest.Mock;
const countDead = localDatabase.countDeadQueueItems as jest.Mock;

describe("syncService", () => {
  beforeEach(() => {
    jest.resetAllMocks();
    countDead.mockResolvedValue(0);
    requireBase.mockResolvedValue("https://api.test");
    readSession.mockResolvedValue({ accessToken: "tok", refreshToken: null, user: null });
    getConfig.mockResolvedValue(null);
    addNetworkStateListener.mockReturnValue({ remove: jest.fn() });
    (globalThis as unknown as { fetch: jest.Mock }).fetch = jest.fn();
    (globalThis as unknown as { FormData: unknown }).FormData = class {
      entries: Array<[string, unknown]> = [];
      append(key: string, value: unknown) {
        this.entries.push([key, value]);
      }
    };
  });

  describe("isNetworkOnline", () => {
    it("true si connected + isInternetReachable non-false", async () => {
      getNetworkStateAsync.mockResolvedValue({ isConnected: true, isInternetReachable: true });
      expect(await isNetworkOnline()).toBe(true);
    });

    it("true si connected et isInternetReachable=undefined", async () => {
      getNetworkStateAsync.mockResolvedValue({ isConnected: true });
      expect(await isNetworkOnline()).toBe(true);
    });

    it("false si pas connecte", async () => {
      getNetworkStateAsync.mockResolvedValue({ isConnected: false, isInternetReachable: true });
      expect(await isNetworkOnline()).toBe(false);
    });

    it("true fallback si throw (pessimist-safe)", async () => {
      getNetworkStateAsync.mockRejectedValue(new Error("x"));
      expect(await isNetworkOnline()).toBe(true);
    });
  });

  describe("subscribeNetworkChanges", () => {
    it("appelle onChange avec true/false selon state", () => {
      const onChange = jest.fn();
      let listener: (s: { isConnected?: boolean; isInternetReachable?: boolean }) => void = () => {};
      addNetworkStateListener.mockImplementation((cb) => {
        listener = cb;
        return { remove: jest.fn() };
      });

      const cleanup = subscribeNetworkChanges(onChange);
      listener({ isConnected: true, isInternetReachable: true });
      listener({ isConnected: false });
      expect(onChange).toHaveBeenNthCalledWith(1, true);
      expect(onChange).toHaveBeenNthCalledWith(2, false);

      cleanup();
    });

    it("cleanup appelle remove()", () => {
      const remove = jest.fn();
      addNetworkStateListener.mockReturnValue({ remove });
      const cleanup = subscribeNetworkChanges(jest.fn());
      cleanup();
      expect(remove).toHaveBeenCalled();
    });
  });

  describe("enqueueOfflineAction", () => {
    it("delegate a enqueueSyncAction", async () => {
      await enqueueOfflineAction({
        endpoint: "/x",
        method: "POST",
        payload: { a: 1 },
        entityType: "t",
        entityId: "1",
      });
      expect(enqueue).toHaveBeenCalledWith({
        endpoint: "/x",
        method: "POST",
        payload: { a: 1 },
        entityType: "t",
        entityId: "1",
      });
    });
  });

  describe("processSyncQueue", () => {
    it("traite POST non-multipart", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/x", method: "POST", payload: { a: 1 } },
      ]);
      post.mockResolvedValue({ data: {} });
      const res = await processSyncQueue();
      expect(res).toEqual({ processed: 1, failed: 0 });
      expect(post).toHaveBeenCalledWith("/x", { a: 1 });
      expect(markDone).toHaveBeenCalledWith(1);
    });

    // Regression R-TEST-002 : les save_step hors-ligne portent une version
    // figee. Sans rebase, apres le 1er save le serveur incremente la version et
    // les suivants meurent en 409 -> etapes perdues. On rebase sur la version
    // courante (seed via GET intervention_steps, puis chainage des reponses).
    it("save_step hors-ligne : rebase la version (seed GET + chainage des reponses)", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/mobile/intervention_workflow.php", method: "POST",
          payload: { action: "save_step", intervention_id: 258, step_key: "a", value: "x", version: 20 } },
        { id: 2, endpoint: "/mobile/intervention_workflow.php", method: "POST",
          payload: { action: "save_step", intervention_id: 258, step_key: "b", value: "y", version: 20 } },
      ]);
      // seed : version courante serveur = 25
      get.mockResolvedValue({ data: { data: { workflow: { version: 25 } } } });
      // chaque save renvoie la nouvelle version
      post
        .mockResolvedValueOnce({ data: { data: { workflow: { version: 26 } } } })
        .mockResolvedValueOnce({ data: { data: { workflow: { version: 27 } } } });

      const res = await processSyncQueue();

      expect(res).toEqual({ processed: 2, failed: 0 });
      // 1 seul GET de seed (version chainee ensuite), pas un par save
      expect(get).toHaveBeenCalledTimes(1);
      // save #1 rebase sur 25 (pas 20), save #2 chaine sur 26 (reponse du #1)
      expect(post).toHaveBeenNthCalledWith(1, "/mobile/intervention_workflow.php",
        expect.objectContaining({ step_key: "a", version: 25 }));
      expect(post).toHaveBeenNthCalledWith(2, "/mobile/intervention_workflow.php",
        expect.objectContaining({ step_key: "b", version: 26 }));
      expect(markDone).toHaveBeenCalledWith(1);
      expect(markDone).toHaveBeenCalledWith(2);
      expect(markDead).not.toHaveBeenCalled();
    });

    it("save_step hors-ligne : un 409 residuel re-seed via GET et retente", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/mobile/intervention_workflow.php", method: "POST",
          payload: { action: "save_step", intervention_id: 258, step_key: "a", value: "x", version: 20 } },
      ]);
      get
        .mockResolvedValueOnce({ data: { data: { workflow: { version: 25 } } } }) // seed
        .mockResolvedValueOnce({ data: { data: { workflow: { version: 30 } } } }); // re-seed apres 409
      post
        .mockRejectedValueOnce(new ApiClientError("stale", 409, "WORKFLOW_VERSION_STALE"))
        .mockResolvedValueOnce({ data: { data: { workflow: { version: 31 } } } });

      const res = await processSyncQueue();

      expect(res).toEqual({ processed: 1, failed: 0 });
      expect(post).toHaveBeenNthCalledWith(2, "/mobile/intervention_workflow.php",
        expect.objectContaining({ version: 30 }));
      expect(markDone).toHaveBeenCalledWith(1);
      expect(markDead).not.toHaveBeenCalled();
    });

    // Couverture du fallback de seed (ligne syncService ~311) : si le GET de
    // seed ne renvoie pas de version exploitable, on retombe sur la version
    // figee dans le payload hors-ligne.
    it("save_step hors-ligne : seed GET sans version -> fallback sur payload.version", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/mobile/intervention_workflow.php", method: "POST",
          payload: { action: "save_step", intervention_id: 258, step_key: "a", value: "x", version: 20 } },
      ]);
      // seed sans workflow.version -> fetchCurrentWorkflowVersion renvoie null
      get.mockResolvedValue({ data: { data: {} } });
      post.mockResolvedValue({ data: { data: { workflow: { version: 21 } } } });

      const res = await processSyncQueue();

      expect(res).toEqual({ processed: 1, failed: 0 });
      expect(post).toHaveBeenNthCalledWith(1, "/mobile/intervention_workflow.php",
        expect.objectContaining({ step_key: "a", version: 20 }));
      expect(markDone).toHaveBeenCalledWith(1);
    });

    // Garde anti-NaN : seed sans version ET payload sans version -> on POST 0,
    // pas NaN (l'ancien `?? 0` ne rattrapait pas Number(undefined) === NaN).
    it("save_step hors-ligne : seed et payload sans version -> POST version 0 (pas NaN)", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/mobile/intervention_workflow.php", method: "POST",
          payload: { action: "save_step", intervention_id: 258, step_key: "a", value: "x" } },
      ]);
      get.mockResolvedValue({ data: { data: {} } });
      post.mockResolvedValue({ data: { data: { workflow: { version: 1 } } } });

      const res = await processSyncQueue();

      expect(res).toEqual({ processed: 1, failed: 0 });
      expect(post).toHaveBeenNthCalledWith(1, "/mobile/intervention_workflow.php",
        expect.objectContaining({ step_key: "a", version: 0 }));
    });

    // M1 : complete_workflow hors-ligne DOIT aussi etre rebase. Sinon, quand des
    // save_step le precedent dans la file, ils incrementent la version serveur et
    // complete_workflow part avec sa version d'origine -> 409 -> dead -> la
    // finalisation est perdue silencieusement.
    it("complete_workflow hors-ligne : rebase la version (apres des save_step qui ont bumpe le serveur)", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/mobile/intervention_workflow.php", method: "POST",
          payload: { action: "save_step", intervention_id: 258, step_key: "a", value: "x", version: 20 } },
        { id: 2, endpoint: "/mobile/intervention_workflow.php", method: "POST",
          payload: { action: "complete_workflow", intervention_id: 258, version: 20,
            signature_technicien: "data:image/png;base64,AAAA", client_present: false } },
      ]);
      // seed : version courante serveur = 25
      get.mockResolvedValue({ data: { data: { workflow: { version: 25 } } } });
      post
        .mockResolvedValueOnce({ data: { data: { workflow: { version: 26 } } } }) // save_step
        .mockResolvedValueOnce({ data: { data: { workflow: { version: 27, status: "completed" } } } }); // complete

      const res = await processSyncQueue();

      expect(res).toEqual({ processed: 2, failed: 0 });
      // 1 seul GET de seed, version chainee ensuite
      expect(get).toHaveBeenCalledTimes(1);
      // save rebase sur 25, complete chaine sur 26 (PAS sa version figee 20)
      expect(post).toHaveBeenNthCalledWith(1, "/mobile/intervention_workflow.php",
        expect.objectContaining({ action: "save_step", version: 25 }));
      expect(post).toHaveBeenNthCalledWith(2, "/mobile/intervention_workflow.php",
        expect.objectContaining({ action: "complete_workflow", version: 26 }));
      expect(markDone).toHaveBeenCalledWith(2);
    });

    it("complete_workflow hors-ligne : un 409 de VERSION (pas locked) re-seed via GET et retente", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/mobile/intervention_workflow.php", method: "POST",
          payload: { action: "complete_workflow", intervention_id: 258, version: 20,
            signature_technicien: "data:image/png;base64,AAAA", client_present: false } },
      ]);
      get
        .mockResolvedValueOnce({ data: { data: { workflow: { version: 25 } } } }) // seed version
        .mockResolvedValueOnce({ data: { data: { workflow: { status: "in_progress" } } } }) // statut apres 409 : PAS completed -> on rebase
        .mockResolvedValueOnce({ data: { data: { workflow: { version: 30 } } } }); // re-seed version
      post
        .mockRejectedValueOnce(new ApiClientError("stale", 409, "WORKFLOW_VERSION_STALE"))
        .mockResolvedValueOnce({ data: { data: { workflow: { version: 31, status: "completed" } } } });

      const res = await processSyncQueue();

      expect(res).toEqual({ processed: 1, failed: 0 });
      expect(post).toHaveBeenNthCalledWith(2, "/mobile/intervention_workflow.php",
        expect.objectContaining({ action: "complete_workflow", version: 30 }));
      expect(markDone).toHaveBeenCalledWith(1);
    });

    // A : complete_workflow rejoue alors que le workflow est DEJA 'completed'
    // (reponse HTTP perdue apres un commit reussi) -> 409 WORKFLOW_LOCKED. La
    // finalisation a en fait REUSSI : on doit considerer l'item done (idempotence),
    // PAS le marquer dead (fausse alerte M4).
    it("complete_workflow : 409 mais workflow deja 'completed' -> succes idempotent (pas dead)", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/mobile/intervention_workflow.php", method: "POST",
          payload: { action: "complete_workflow", intervention_id: 258, version: 20,
            signature_technicien: "data:image/png;base64,AAAA", client_present: false } },
      ]);
      // seed version
      get
        .mockResolvedValueOnce({ data: { data: { workflow: { version: 25 } } } }) // seed version
        .mockResolvedValueOnce({ data: { data: { workflow: { status: "completed" } } } }); // statut apres 409
      post.mockRejectedValueOnce(new ApiClientError("locked", 409, "WORKFLOW_LOCKED"));

      const res = await processSyncQueue();

      expect(res).toEqual({ processed: 1, failed: 0 });
      expect(markDone).toHaveBeenCalledWith(1);
      expect(markDead).not.toHaveBeenCalled();
    });

    // M5 : 413 (photo trop grosse) = definitif. On ne rejoue PAS 8x, on marque
    // l'item dead immediatement et on continue.
    it("erreur 413 (payload trop grand) marque l'item dead (pas de retry)", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/up", method: "POST", payload: {} },
        { id: 2, endpoint: "/b", method: "POST", payload: {} },
      ]);
      post
        .mockRejectedValueOnce(new ApiClientError("too large", 413))
        .mockResolvedValueOnce({ data: {} });
      const res = await processSyncQueue();
      expect(res).toEqual({ processed: 1, failed: 1 });
      expect(markDead).toHaveBeenCalledWith(1, "too large");
      expect(markFailed).not.toHaveBeenCalled();
      expect(markDone).toHaveBeenCalledWith(2);
    });

    it("traite GET avec params primitifs", async () => {
      getPending.mockResolvedValue([
        {
          id: 1,
          endpoint: "/x",
          method: "GET",
          payload: { a: 1, b: "s", c: true, nested: { bad: 1 } },
        },
      ]);
      get.mockResolvedValue({ data: {} });
      await processSyncQueue();
      expect(get).toHaveBeenCalledWith("/x", { params: { a: 1, b: "s", c: true } });
    });

    it("POST multipart via fetch direct", async () => {
      getPending.mockResolvedValue([
        {
          id: 1,
          endpoint: "/up",
          method: "POST",
          payload: {
            __multipart: true,
            upload_files: [{ field: "file", uri: "u", name: "n", type: "image/jpeg" }],
            workflow_step_id: 5,
            label: "x",
            truthy: true,
            nothing: null,
            obj: { x: 1 },
            ignored: undefined,
          },
        },
      ]);
      (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200 });
      const res = await processSyncQueue();
      expect(res).toEqual({ processed: 1, failed: 0 });
      expect(globalThis.fetch).toHaveBeenCalled();
    });

    it("multipart sans tenant n'ajoute pas X-Tenant-Id", async () => {
      getConfig.mockResolvedValue({ config: { tenant_id: null } });
      getPending.mockResolvedValue([
        {
          id: 1,
          endpoint: "/up",
          method: "POST",
          payload: { __multipart: true, upload_files: [] },
        },
      ]);
      (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200 });
      await processSyncQueue();
      const headers = (globalThis.fetch as jest.Mock).mock.calls[0][1].headers;
      expect(headers["X-Tenant-Id"]).toBeUndefined();
    });

    it("multipart avec tenant ajoute X-Tenant-Id", async () => {
      getConfig.mockResolvedValue({ config: { tenant_id: 42 } });
      getPending.mockResolvedValue([
        {
          id: 1,
          endpoint: "/up",
          method: "POST",
          payload: { __multipart: true, upload_files: [] },
        },
      ]);
      (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200 });
      await processSyncQueue();
      const headers = (globalThis.fetch as jest.Mock).mock.calls[0][1].headers;
      expect(headers["X-Tenant-Id"]).toBe("42");
    });

    it("multipart endpoint absolu est utilise tel quel", async () => {
      getPending.mockResolvedValue([
        {
          id: 1,
          endpoint: "https://other.test/up",
          method: "POST",
          payload: { __multipart: true, upload_files: [] },
        },
      ]);
      (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200 });
      await processSyncQueue();
      expect((globalThis.fetch as jest.Mock).mock.calls[0][0]).toBe("https://other.test/up");
    });

    it("multipart echec HTTP sans JSON", async () => {
      getPending.mockResolvedValue([
        {
          id: 1,
          endpoint: "/up",
          method: "POST",
          payload: { __multipart: true, upload_files: [] },
        },
      ]);
      (globalThis.fetch as jest.Mock).mockResolvedValue({
        ok: false,
        status: 500,
        json: async () => {
          throw new Error("p");
        },
      });
      const res = await processSyncQueue();
      expect(res).toEqual({ processed: 0, failed: 1 });
      expect(markFailed).toHaveBeenCalledWith(1, "HTTP 500");
    });

    it("multipart echec HTTP JSON sans message -> HTTP status fallback", async () => {
      getPending.mockResolvedValue([
        {
          id: 1,
          endpoint: "/up",
          method: "POST",
          payload: { __multipart: true, upload_files: [] },
        },
      ]);
      (globalThis.fetch as jest.Mock).mockResolvedValue({
        ok: false,
        status: 503,
        json: async () => ({ ok: false }),
      });
      await processSyncQueue();
      expect(markFailed).toHaveBeenCalledWith(1, "HTTP 503");
    });

    it("multipart echec HTTP avec message JSON", async () => {
      getPending.mockResolvedValue([
        {
          id: 1,
          endpoint: "/up",
          method: "POST",
          payload: { __multipart: true, upload_files: [] },
        },
      ]);
      (globalThis.fetch as jest.Mock).mockResolvedValue({
        ok: false,
        status: 400,
        json: async () => ({ message: "bad" }),
      });
      await processSyncQueue();
      // 400 = erreur de contrat -> item sorti de la file (dead), pas un retry.
      expect(markDead).toHaveBeenCalledWith(1, "bad");
      expect(markFailed).not.toHaveBeenCalled();
    });

    it("multipart sans session -> 401 non-retryable, stop", async () => {
      readSession.mockResolvedValue({ accessToken: null, refreshToken: null, user: null });
      getPending.mockResolvedValue([
        {
          id: 1,
          endpoint: "/up",
          method: "POST",
          payload: { __multipart: true, upload_files: [] },
        },
        { id: 2, endpoint: "/x", method: "POST", payload: {} },
      ]);
      const res = await processSyncQueue();
      expect(res.failed).toBe(1);
      expect(post).not.toHaveBeenCalled();
    });

    it("erreur retryable continue le traitement", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/a", method: "POST", payload: {} },
        { id: 2, endpoint: "/b", method: "POST", payload: {} },
      ]);
      post
        .mockRejectedValueOnce(new Error("net"))
        .mockResolvedValueOnce({ data: {} });
      const res = await processSyncQueue();
      expect(res).toEqual({ processed: 1, failed: 1 });
    });

    it("erreur de contrat (422) marque l'item dead et CONTINUE les suivants", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/a", method: "POST", payload: {} },
        { id: 2, endpoint: "/b", method: "POST", payload: {} },
      ]);
      post
        .mockRejectedValueOnce(new ApiClientError("bad", 422))
        .mockResolvedValueOnce({ data: {} });
      const res = await processSyncQueue();
      // item 1 -> dead (ne bloque plus la file), item 2 -> envoye avec succes.
      expect(res).toEqual({ processed: 1, failed: 1 });
      expect(markDead).toHaveBeenCalledWith(1, "bad");
      expect(markDone).toHaveBeenCalledWith(2);
    });

    it("erreur de contrat (404) ne bloque pas la file (regression endpoint fantome)", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/missions/1/steps/s/complete", method: "POST", payload: {} },
        { id: 2, endpoint: "/mobile/intervention_workflow.php", method: "POST", payload: {} },
      ]);
      post
        .mockRejectedValueOnce(new ApiClientError("Not Found", 404))
        .mockResolvedValueOnce({ data: {} });
      const res = await processSyncQueue();
      expect(res).toEqual({ processed: 1, failed: 1 });
      expect(markDead).toHaveBeenCalledWith(1, "Not Found");
    });

    it("erreur auth (401) STOPPE la passe sans tuer les items", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/a", method: "POST", payload: {} },
        { id: 2, endpoint: "/b", method: "POST", payload: {} },
      ]);
      post.mockRejectedValueOnce(new ApiClientError("Unauthorized", 401));
      const res = await processSyncQueue();
      expect(res).toEqual({ processed: 0, failed: 1 });
      expect(markFailed).toHaveBeenCalledWith(1, "Unauthorized");
      expect(markDead).not.toHaveBeenCalled();
      // item 2 jamais tente : on s'arrete a la premiere erreur d'auth.
      expect(post).toHaveBeenCalledTimes(1);
    });

    it("mur de facturation (402 BILLING_WALL) garde l'item pending, stoppe la passe et notifie une fois", async () => {
      const onWall = jest.fn();
      registerBillingWallHandler(onWall);
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/mobile/intervention_workflow.php", method: "POST", payload: {} },
        { id: 2, endpoint: "/b", method: "POST", payload: {} },
      ]);
      post.mockRejectedValueOnce(
        new ApiClientError("Abonnement requis.", 402, "BILLING_WALL")
      );
      const res = await processSyncQueue();
      expect(res).toEqual({ processed: 0, failed: 1 });
      // item garde 'pending' SANS attempts++ (pas markFailed/markDead) -> ne meurt
      // pas au cap, se re-poussera a la reactivation.
      expect(markWalled).toHaveBeenCalledWith(1, "Abonnement requis.");
      expect(markFailed).not.toHaveBeenCalled();
      expect(markDead).not.toHaveBeenCalled();
      // passe stoppee : item 2 jamais tente (tout serait mure pareil).
      expect(post).toHaveBeenCalledTimes(1);
      // UI prevenue une seule fois, avec le message serveur.
      expect(onWall).toHaveBeenCalledTimes(1);
      expect(onWall).toHaveBeenCalledWith("Abonnement requis.");
      registerBillingWallHandler(null);
    });

    it("un 402 SANS code BILLING_WALL reste retryable (pas de mur)", async () => {
      getPending.mockResolvedValue([{ id: 1, endpoint: "/a", method: "POST", payload: {} }]);
      post.mockRejectedValueOnce(new ApiClientError("Trial expire", 402, "TRIAL_EXPIRED"));
      const res = await processSyncQueue();
      expect(res).toEqual({ processed: 0, failed: 1 });
      expect(markWalled).not.toHaveBeenCalled();
      // retombe dans le defaut retryable -> attempts++ via markFailed.
      expect(markFailed).toHaveBeenCalledWith(1, "Trial expire");
    });

    it("erreur non-Error → message 'Sync failed'", async () => {
      getPending.mockResolvedValue([{ id: 1, endpoint: "/a", method: "POST", payload: {} }]);
      post.mockRejectedValue("string-err");
      await processSyncQueue();
      expect(markFailed).toHaveBeenCalledWith(1, "Sync failed");
    });

    it("isNonRetryableSyncError retourne false si status absent", async () => {
      // ApiClientError sans status numerique
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/a", method: "POST", payload: {} },
        { id: 2, endpoint: "/b", method: "POST", payload: {} },
      ]);
      const errNoStatus = new ApiClientError("x");
      post.mockRejectedValueOnce(errNoStatus).mockResolvedValueOnce({ data: {} });
      const res = await processSyncQueue();
      expect(res.processed).toBe(1);
    });
  });

  describe("isMultipartQueuePayload — branches", () => {
    it("payload sans __multipart=true est POST normal", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/x", method: "POST", payload: { __multipart: false, upload_files: [] } },
      ]);
      post.mockResolvedValue({ data: {} });
      await processSyncQueue();
      expect(post).toHaveBeenCalled();
      expect(globalThis.fetch).not.toHaveBeenCalled();
    });

    it("payload sans upload_files array est POST normal", async () => {
      getPending.mockResolvedValue([
        { id: 1, endpoint: "/x", method: "POST", payload: { __multipart: true, upload_files: "bad" } },
      ]);
      post.mockResolvedValue({ data: {} });
      await processSyncQueue();
      expect(post).toHaveBeenCalled();
    });

    it("upload_files contenant un fichier mal forme est POST normal", async () => {
      getPending.mockResolvedValue([
        {
          id: 1,
          endpoint: "/x",
          method: "POST",
          payload: { __multipart: true, upload_files: [{ field: "f", uri: "u", name: "n" }] },
        },
      ]);
      post.mockResolvedValue({ data: {} });
      await processSyncQueue();
      expect(post).toHaveBeenCalled();
    });

    it("upload_files contenant null est POST normal", async () => {
      getPending.mockResolvedValue([
        {
          id: 1,
          endpoint: "/x",
          method: "POST",
          payload: { __multipart: true, upload_files: [null] },
        },
      ]);
      post.mockResolvedValue({ data: {} });
      await processSyncQueue();
      expect(post).toHaveBeenCalled();
    });
  });

  describe("postMultipartFromQueue — field par defaut files[]", () => {
    it("utilise files[] si field absent (fichier avec field vide)", async () => {
      getPending.mockResolvedValue([
        {
          id: 1,
          endpoint: "/up",
          method: "POST",
          payload: {
            __multipart: true,
            upload_files: [{ field: "", uri: "u", name: "n", type: "t" }],
          },
        },
      ]);
      (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200 });
      await processSyncQueue();
      expect(globalThis.fetch).toHaveBeenCalled();
    });

    it("upload photo muré (402 BILLING_WALL) extrait le code du body réel et garde l'item pending", async () => {
      getPending.mockResolvedValue([
        {
          id: 7,
          endpoint: "/mobile/intervention_workflow_files.php",
          method: "POST",
          payload: {
            __multipart: true,
            upload_files: [{ field: "file", uri: "u", name: "n", type: "image/jpeg" }],
          },
        },
      ]);
      // Body REEL du serveur (response402, commit ead5778b) : code sous `code`.
      (globalThis.fetch as jest.Mock).mockResolvedValue({
        ok: false,
        status: 402,
        json: async () => ({
          success: false,
          error: "BILLING_WALL",
          code: "BILLING_WALL",
          message: "Abonnement requis.",
        }),
      });
      const res = await processSyncQueue();
      expect(res).toEqual({ processed: 0, failed: 1 });
      expect(markWalled).toHaveBeenCalledWith(7, "Abonnement requis.");
      expect(markFailed).not.toHaveBeenCalled();
      expect(markDead).not.toHaveBeenCalled();
    });
  });

  describe("pullSyncDelta", () => {
    it("utilise epoch (format MySQL) si pas de dernier sync", async () => {
      getLastSync.mockResolvedValue(null);
      get.mockResolvedValue({ data: { success: true, data: { items: [], steps: [], intervention_ids: [] } } });
      await pullSyncDelta();
      expect(get).toHaveBeenCalledWith("/mobile/sync_pull.php", { params: { since: "1970-01-01 00:00:00" } });
    });

    it("utilise lastSync si present", async () => {
      getLastSync.mockResolvedValue("2026-01-01 00:00:00");
      get.mockResolvedValue({ data: { success: true, data: { items: [], steps: [], intervention_ids: [] } } });
      await pullSyncDelta();
      expect(get).toHaveBeenCalledWith("/mobile/sync_pull.php", { params: { since: "2026-01-01 00:00:00" } });
    });

    it("retourne 0 si !success", async () => {
      getLastSync.mockResolvedValue(null);
      get.mockResolvedValue({ data: { success: false } });
      expect(await pullSyncDelta()).toBe(0);
    });

    it("merge items + ids, applique steps, watermark depuis server_time", async () => {
      getLastSync.mockResolvedValue(null);
      get.mockResolvedValue({
        data: { success: true, data: {
          items: [{ id: 1 }],
          intervention_ids: [1, 2, 3],
          steps: [{ id: "s" }],
          server_time: "2026-06-06 10:00:00",
        } },
      });
      applyDelta.mockResolvedValue(1);
      const res = await pullSyncDelta();
      expect(mergeSnap).toHaveBeenCalledWith([{ id: 1 }], [1, 2, 3]);
      expect(applyDelta).toHaveBeenCalledWith([{ id: "s" }]);
      expect(setLastSync).toHaveBeenCalledWith("2026-06-06 10:00:00");
      expect(res).toBe(2);
    });

    it("fallback sur payload.interventions si items absent", async () => {
      getLastSync.mockResolvedValue(null);
      get.mockResolvedValue({
        data: { success: true, data: { interventions: [{ id: 1 }], intervention_ids: [1] } },
      });
      const res = await pullSyncDelta();
      expect(mergeSnap).toHaveBeenCalledWith([{ id: 1 }], [1]);
      expect(res).toBe(1);
    });

    it("purge meme sans items quand intervention_ids fourni", async () => {
      getLastSync.mockResolvedValue(null);
      get.mockResolvedValue({ data: { success: true, data: { items: [], intervention_ids: [7] } } });
      const res = await pullSyncDelta();
      expect(mergeSnap).toHaveBeenCalledWith([], [7]);
      expect(res).toBe(0);
    });

    it("aucun merge si payload sans items ni intervention_ids", async () => {
      getLastSync.mockResolvedValue(null);
      get.mockResolvedValue({ data: { success: true, data: {} } });
      const res = await pullSyncDelta();
      expect(mergeSnap).not.toHaveBeenCalled();
      expect(applyDelta).not.toHaveBeenCalled();
      expect(res).toBe(0);
    });

    it("ne pose pas de watermark si server_time absent", async () => {
      getLastSync.mockResolvedValue(null);
      get.mockResolvedValue({ data: { success: true, data: { items: [{ id: 1 }], intervention_ids: [1] } } });
      await pullSyncDelta();
      expect(setLastSync).not.toHaveBeenCalled();
    });
  });

  describe("syncOnReconnect", () => {
    it("pushed+failed depuis processSyncQueue, pulled depuis pullSyncDelta", async () => {
      getPending.mockResolvedValue([{ id: 1, endpoint: "/x", method: "POST", payload: {} }]);
      post.mockResolvedValue({ data: {} });
      getLastSync.mockResolvedValue(null);
      get.mockResolvedValue({ data: { success: true, data: { items: [{ id: 9 }] } } });
      const res = await syncOnReconnect();
      expect(res).toEqual({ pushed: 1, failed: 0, pulled: 1, dead: 0 });
    });

    // M4 (corrige) : on remonte le DELTA de morts de la passe, pas le cumul. Ici
    // 1 mort avant la passe, 3 apres -> 2 nouvelles morts signalees.
    it("remonte le DELTA d'actions 'dead' de la passe (M4)", async () => {
      getPending.mockResolvedValue([]);
      getLastSync.mockResolvedValue(null);
      get.mockResolvedValue({ data: { success: true, data: { items: [] } } });
      countDead.mockReset();
      countDead.mockResolvedValueOnce(1).mockResolvedValueOnce(3); // before, after
      const res = await syncOnReconnect();
      expect(res.dead).toBe(2);
    });

    it("ne re-signale PAS les morts cumulees des passes precedentes (delta 0)", async () => {
      getPending.mockResolvedValue([]);
      getLastSync.mockResolvedValue(null);
      get.mockResolvedValue({ data: { success: true, data: { items: [] } } });
      countDead.mockReset();
      countDead.mockResolvedValueOnce(5).mockResolvedValueOnce(5); // before == after
      const res = await syncOnReconnect();
      expect(res.dead).toBe(0);
    });

    it("pulled=0 si pullSyncDelta throw", async () => {
      getPending.mockResolvedValue([]);
      getLastSync.mockResolvedValue(null);
      get.mockRejectedValue(new Error("x"));
      const res = await syncOnReconnect();
      expect(res.pulled).toBe(0);
    });

    it("garde in-flight : un 2e appel concurrent est skip (pas de double envoi)", async () => {
      let releasePending: (v: unknown[]) => void = () => {};
      getPending.mockReturnValue(
        new Promise<unknown[]>((resolve) => {
          releasePending = resolve;
        })
      );
      getLastSync.mockResolvedValue(null);
      get.mockResolvedValue({ data: { success: true, data: { items: [] } } });

      const first = syncOnReconnect(); // reste bloque sur getPending
      const second = await syncOnReconnect(); // doit etre court-circuite
      expect(second).toEqual({ pushed: 0, failed: 0, pulled: 0, dead: 0, skipped: true });

      releasePending([]);
      await first; // libere le verrou pour les tests suivants
    });
  });
});
