import React from "react";
import { render, fireEvent, act, waitFor } from "@testing-library/react-native";
import { Alert } from "react-native";

// Mock react-navigation : rendu plat des enfants
jest.mock("@react-navigation/native", () => ({
  NavigationContainer: ({ children }: { children: unknown }) => children,
  // navigationRef.ts appelle createNavigationContainerRef() au chargement du
  // module : stub minimal (pas de tap notification dans ce test).
  createNavigationContainerRef: () => ({
    isReady: () => false,
    getRootState: () => undefined,
    navigate: () => {},
  }),
}));
const mockNavigate = jest.fn();
jest.mock("@react-navigation/native-stack", () => {
  const ReactLocal = require("react");
  function Screen({ children, component }: {
    children?: (p: { navigation: unknown }) => unknown;
    component?: React.ComponentType<{ navigation: unknown }>;
  }) {
    const navigation = { navigate: mockNavigate };
    if (typeof children === "function") {
      return children({ navigation });
    }
    if (component) {
      return ReactLocal.createElement(component, { navigation });
    }
    return null;
  }
  // Rend TOUS les ecrans de la pile a plat : l'accueil est desormais un hub de
  // cartes et la liste des missions est un ecran separe. Un rendu "premier
  // enfant seulement" ne montrerait que le hub et empecherait de tester les
  // ecrans atteints par navigation (liste, etc.).
  function Navigator({ children }: { children: unknown }) {
    const ReactInner = require("react");
    return ReactInner.createElement(ReactInner.Fragment, null, children);
  }
  return {
    createNativeStackNavigator: () => ({ Navigator, Screen }),
  };
});

jest.mock("expo-status-bar", () => ({
  StatusBar: () => null,
}));

const mockCameraScan = { lastOnBarcode: null as null | ((e: { data: string }) => void) };
jest.mock("expo-camera", () => {
  const ReactLocal = require("react");
  const { View } = require("react-native");
  const CameraView = (props: { onBarcodeScanned?: (e: { data: string }) => void }) => {
    mockCameraScan.lastOnBarcode = props.onBarcodeScanned ?? null;
    return ReactLocal.createElement(View, null);
  };
  return {
    CameraView,
    useCameraPermissions: () => [{ granted: true }, jest.fn().mockResolvedValue({ granted: true })],
  };
});

jest.mock("../src/features/missions/MissionDetailScreen", () => () => null);
jest.mock("../src/features/missions/WorkflowInterventionScreen", () => () => null);

// Mocks des services
jest.mock("../src/services/tokenStorage", () => ({
  readSession: jest.fn(),
  saveSession: jest.fn(),
  clearSession: jest.fn(),
}));
jest.mock("../src/services/authApi", () => ({
  login: jest.fn(),
  me: jest.fn(),
  logout: jest.fn(),
}));
jest.mock("../src/services/interventionsApi", () => ({
  getMyInterventions: jest.fn(),
}));
jest.mock("../src/services/precheckDraftStorage", () => ({
  clearAllPrecheckDrafts: jest.fn(),
}));
jest.mock("../src/services/apiClient", () => {
  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;
    }
  }
  return {
    ApiClientError,
    isApiClientError: (e: unknown) => e instanceof ApiClientError,
    clearApiAuthTokens: jest.fn(),
    initializeApiClientAuth: jest.fn(),
    registerSessionRevokedHandler: jest.fn(),
    setApiAuthTokens: jest.fn(),
  };
});
jest.mock("../src/services/offlineCredentialStore", () => ({
  saveOfflineCredential: jest.fn(),
  verifyOfflineCredential: jest.fn(),
  clearOfflineCredential: jest.fn(),
}));
jest.mock("../src/core/environmentService", () => ({
  clearEnvironmentConfig: jest.fn(),
  getEnvironmentBaseUrl: jest.fn(),
  getInviteToken: jest.fn(),
  saveInviteToken: jest.fn(),
  setEnvironmentBaseUrl: jest.fn(),
}));
jest.mock("../src/core/mobileConfigService", () => ({
  clearMobileConfig: jest.fn(),
  getMobileConfigForBaseUrl: jest.fn(),
  saveMobileConfig: jest.fn(),
}));
jest.mock("../src/services/mobileConfigApi", () => ({
  fetchMobileConfig: jest.fn(),
}));
jest.mock("../src/core/localDatabase", () => ({
  clearLocalDatabase: jest.fn(),
  initializeLocalDatabase: jest.fn(),
  getPendingQueueItems: jest.fn().mockResolvedValue([]),
  getInterventionsSnapshot: jest.fn().mockResolvedValue({ items: [] }),
  getLocalDbOwnerId: jest.fn().mockResolvedValue(null),
  setLocalDbOwnerId: jest.fn(),
}));
const mockSyncState = { listeners: [] as Array<(online: boolean) => void> };
jest.mock("../src/services/syncService", () => ({
  isNetworkOnline: jest.fn(),
  subscribeNetworkChanges: jest.fn((cb: (online: boolean) => void) => {
    mockSyncState.listeners.push(cb);
    return () => {};
  }),
  syncOnReconnect: jest.fn(),
  registerBillingWallHandler: jest.fn(),
}));

import * as tokenStorage from "../src/services/tokenStorage";
import * as authApi from "../src/services/authApi";
import * as interventionsApi from "../src/services/interventionsApi";
import * as apiClient from "../src/services/apiClient";
import * as envService from "../src/core/environmentService";
import * as mobileConfigService from "../src/core/mobileConfigService";
import * as mobileConfigApi from "../src/services/mobileConfigApi";
import * as localDatabase from "../src/core/localDatabase";
import * as syncService from "../src/services/syncService";
import * as precheckDraftStorage from "../src/services/precheckDraftStorage";
import * as offlineCred from "../src/services/offlineCredentialStore";
import App from "../App";

const readSession = tokenStorage.readSession as jest.Mock;
const saveSession = tokenStorage.saveSession as jest.Mock;
const clearSession = tokenStorage.clearSession as jest.Mock;
const login = authApi.login as jest.Mock;
const me = authApi.me as jest.Mock;
const logout = authApi.logout as jest.Mock;
const getMy = interventionsApi.getMyInterventions as jest.Mock;
const clearApiAuthTokens = apiClient.clearApiAuthTokens as jest.Mock;
const initializeApiClientAuth = apiClient.initializeApiClientAuth as jest.Mock;
const setApiAuthTokens = apiClient.setApiAuthTokens as jest.Mock;
const verifyOfflineCred = offlineCred.verifyOfflineCredential as jest.Mock;
const saveOfflineCred = offlineCred.saveOfflineCredential as jest.Mock;
const getEnvBaseUrl = envService.getEnvironmentBaseUrl as jest.Mock;
const getInvite = envService.getInviteToken as jest.Mock;
const saveInvite = envService.saveInviteToken as jest.Mock;
const setEnvBaseUrl = envService.setEnvironmentBaseUrl as jest.Mock;
const clearEnv = envService.clearEnvironmentConfig as jest.Mock;
const getConfigFor = mobileConfigService.getMobileConfigForBaseUrl as jest.Mock;
const saveConfig = mobileConfigService.saveMobileConfig as jest.Mock;
const clearConfig = mobileConfigService.clearMobileConfig as jest.Mock;
const fetchConfig = mobileConfigApi.fetchMobileConfig as jest.Mock;
const initDb = localDatabase.initializeLocalDatabase as jest.Mock;
const clearDb = localDatabase.clearLocalDatabase as jest.Mock;
const isOnlineMock = syncService.isNetworkOnline as jest.Mock;
const subscribeNetwork = syncService.subscribeNetworkChanges as jest.Mock;
const syncOnReconnect = syncService.syncOnReconnect as jest.Mock;
const clearDrafts = precheckDraftStorage.clearAllPrecheckDrafts as jest.Mock;

const user = { id: 1, email: "a@b.c", name: "A", role: "tech", user_type: "t" };
const config = {
  app_name: "MissioFlow",
  primary_color: "#1E56A8",
  logo_url: null,
  tenant_id: null,
};

describe("App", () => {
  beforeEach(() => {
    jest.clearAllMocks();
    mockSyncState.listeners = [];
    // Defaults : pas de boot session, pas de env
    readSession.mockResolvedValue({ accessToken: null, refreshToken: null, user: null });
    getEnvBaseUrl.mockResolvedValue(null);
    getInvite.mockResolvedValue(null);
    initDb.mockResolvedValue(undefined);
    initializeApiClientAuth.mockResolvedValue(undefined);
    isOnlineMock.mockResolvedValue(true);
    syncOnReconnect.mockResolvedValue({ pushed: 0, failed: 0, pulled: 0, dead: 0 });
    getMy.mockResolvedValue({ items: [], count: 0, technicien_id: 0, date_debut: "", date_fin: "" });
  });

  it("boot sans env configuree -> affiche Onboarding", async () => {
    const { findByText } = render(<App />);
    await findByText("MissioFlow Mobile"); // onboarding rendu
  });

  it("boot avec env + config mais sans session -> affiche Login", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: null, refreshToken: null, user: null });
    const { findByText } = render(<App />);
    await findByText("Se connecter");
  });

  it("boot avec session valide -> charge interventions et affiche Dashboard", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockResolvedValue(user);
    const { findByText } = render(<App />);
    await findByText("Tableau de bord"); // hub d'accueil
  });

  it("M3 : boot avec session valide + en ligne -> draine la file d'ecriture (syncOnReconnect)", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockResolvedValue(user);
    isOnlineMock.mockResolvedValue(true);

    const { findByText } = render(<App />);
    await findByText("Tableau de bord");

    // La file hors-ligne de la veille doit repartir des le boot, sans attendre un
    // changement reseau / foreground.
    await waitFor(() => expect(syncOnReconnect).toHaveBeenCalled());
  });

  it("M3 : boot hors-ligne -> NE draine PAS au boot (pas de reseau)", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockResolvedValue(user);
    isOnlineMock.mockResolvedValue(false);

    const { findByText } = render(<App />);
    await findByText("Tableau de bord");

    expect(syncOnReconnect).not.toHaveBeenCalled();
  });

  it("notification recue en etant connecte -> reloadIfLogged recharge la liste", async () => {
    const Notifications = require("expo-notifications");
    const addReceived = Notifications.addNotificationReceivedListener as jest.Mock;
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockResolvedValue(user);
    const { findByText } = render(<App />);
    await findByText("Tableau de bord"); // connecte

    // Le listener mount-once a capture reloadIfLogged. On le declenche en etant
    // connecte (userRef.current truthy) -> rechargement de la liste.
    getMy.mockClear();
    const reloadIfLogged = addReceived.mock.calls.at(-1)[0];
    await act(async () => {
      reloadIfLogged();
    });
    await waitFor(() => expect(getMy).toHaveBeenCalled());
  });

  it("boot avec env mais pas de config cachee -> reste sur onboarding", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue(null);
    const { findByText } = render(<App />);
    await findByText(/Renseigne l'URL/);
  });

  it("boot avec me() qui throw -> clear session et affiche onboarding", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockRejectedValue(new Error("boom"));
    render(<App />);
    await waitFor(() => expect(clearSession).toHaveBeenCalled());
  });

  it("boot hors-ligne (erreur reseau sans status) + user en cache -> session restauree, PAS de clear", async () => {
    const { ApiClientError } = apiClient as unknown as {
      ApiClientError: new (m: string, s?: number) => Error;
    };
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockRejectedValue(new ApiClientError("Network error")); // status undefined
    const { findByText } = render(<App />);
    await findByText("Tableau de bord"); // session optimiste -> dashboard
    expect(clearSession).not.toHaveBeenCalled();
    expect(setApiAuthTokens).toHaveBeenCalledWith("t", "r");
  });

  it("boot avec vraie 401 (status defini) -> clear session meme avec user en cache", async () => {
    const { ApiClientError } = apiClient as unknown as {
      ApiClientError: new (m: string, s?: number) => Error;
    };
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockRejectedValue(new ApiClientError("Unauthorized", 401));
    render(<App />);
    await waitFor(() => expect(clearSession).toHaveBeenCalled());
  });

  it("Onboarding: onSaveEnvironment succes -> passe a Login", async () => {
    setEnvBaseUrl.mockResolvedValue("https://api.test");
    fetchConfig.mockResolvedValue(config);
    saveConfig.mockResolvedValue(undefined);
    const { findByText, getByText, getByPlaceholderText } = render(<App />);
    await findByText(/Renseigne l'URL/);
    fireEvent.changeText(getByPlaceholderText(/instance/), "api.test");
    await act(async () => {
      fireEvent.press(getByText("Enregistrer URL"));
    });
    await findByText("Se connecter");
  });

  it("Onboarding: onSaveEnvironment echec -> efface env + affiche erreur", async () => {
    setEnvBaseUrl.mockResolvedValue("https://api.test");
    fetchConfig.mockRejectedValue(new Error("instance down"));
    const { findByText, getByText, getByPlaceholderText } = render(<App />);
    await findByText(/Renseigne l'URL/);
    fireEvent.changeText(getByPlaceholderText(/instance/), "api.test");
    await act(async () => {
      fireEvent.press(getByText("Enregistrer URL"));
    });
    await findByText("instance down");
  });

  it("Onboarding: onSaveEnvironment echec non-Error -> message fallback", async () => {
    setEnvBaseUrl.mockRejectedValue("str-err");
    const { findByText, getByText, getByPlaceholderText } = render(<App />);
    await findByText(/Renseigne l'URL/);
    fireEvent.changeText(getByPlaceholderText(/instance/), "api.test");
    await act(async () => {
      fireEvent.press(getByText("Enregistrer URL"));
    });
    await findByText(/Instance inaccessible ou invalide/);
  });

  it("Login: onLogin sans creds -> erreur", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    const { findByText, getByText } = render(<App />);
    await findByText("Se connecter");
    await act(async () => {
      fireEvent.press(getByText("Se connecter"));
    });
    await findByText(/Email et mot de passe sont requis/);
  });

  it("Login: onLogin succes -> passe au dashboard", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    login.mockResolvedValue({
      access_token: "acc",
      refresh_token: "ref",
      user,
      token_type: "Bearer",
      expires_in: 3600,
      refresh_expires_in: 86400,
    });
    const { findByText, getByPlaceholderText, getByText } = render(<App />);
    await findByText("Se connecter");
    fireEvent.changeText(getByPlaceholderText("Email"), "a@b.c");
    fireEvent.changeText(getByPlaceholderText("Mot de passe"), "pw");
    await act(async () => {
      fireEvent.press(getByText("Se connecter"));
    });
    await waitFor(() => expect(saveSession).toHaveBeenCalled());
  });

  it("Login: compte different sur le device -> purge le cache local (anti-fuite inter-comptes)", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    // Le cache appartenait au technicien 999 ; user.id vaut 1.
    (localDatabase.getLocalDbOwnerId as jest.Mock).mockResolvedValueOnce(999);
    login.mockResolvedValue({
      access_token: "acc",
      refresh_token: "ref",
      user,
      token_type: "Bearer",
      expires_in: 3600,
      refresh_expires_in: 86400,
    });
    const { findByText, getByPlaceholderText, getByText } = render(<App />);
    await findByText("Se connecter");
    fireEvent.changeText(getByPlaceholderText("Email"), "a@b.c");
    fireEvent.changeText(getByPlaceholderText("Mot de passe"), "pw");
    await act(async () => {
      fireEvent.press(getByText("Se connecter"));
    });
    await waitFor(() => expect(clearDb).toHaveBeenCalled());
    expect(localDatabase.setLocalDbOwnerId).toHaveBeenCalledWith(1);
  });

  it("Login succes -> memorise le credential offline (Argon2id)", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    login.mockResolvedValue({
      access_token: "acc",
      refresh_token: "ref",
      user,
      token_type: "Bearer",
      expires_in: 3600,
      refresh_expires_in: 86400,
    });
    const { findByText, getByPlaceholderText, getByText } = render(<App />);
    await findByText("Se connecter");
    fireEvent.changeText(getByPlaceholderText("Email"), "a@b.c");
    fireEvent.changeText(getByPlaceholderText("Mot de passe"), "pw");
    await act(async () => {
      fireEvent.press(getByText("Se connecter"));
    });
    await waitFor(() =>
      expect(saveOfflineCred).toHaveBeenCalledWith("https://api.test", "a@b.c", "pw", user)
    );
  });

  it("Login hors-ligne (erreur reseau) + credential valide -> session offline, sans token", async () => {
    const { ApiClientError } = apiClient as unknown as {
      ApiClientError: new (m: string, s?: number) => Error;
    };
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    login.mockRejectedValue(new ApiClientError("Network error")); // status undefined
    verifyOfflineCred.mockResolvedValue(user); // credential reconnu
    // readSession reste {null,null,null} -> pas de tokens (post-deconnexion)
    const { findByText, getByPlaceholderText, getByText } = render(<App />);
    await findByText("Se connecter");
    fireEvent.changeText(getByPlaceholderText("Email"), "a@b.c");
    fireEvent.changeText(getByPlaceholderText("Mot de passe"), "pw");
    await act(async () => {
      fireEvent.press(getByText("Se connecter"));
    });
    await findByText("Tableau de bord"); // session offline restauree
    expect(setApiAuthTokens).toHaveBeenCalledWith(null, null);
    expect(localDatabase.setLocalDbOwnerId).toHaveBeenCalledWith(1);
  });

  it("Login hors-ligne + credential NON reconnu -> reste sur login avec erreur", async () => {
    const { ApiClientError } = apiClient as unknown as {
      ApiClientError: new (m: string, s?: number) => Error;
    };
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    login.mockRejectedValue(new ApiClientError("Network error"));
    verifyOfflineCred.mockResolvedValue(null); // pas de match
    const { findByText, getByPlaceholderText, getByText } = render(<App />);
    await findByText("Se connecter");
    fireEvent.changeText(getByPlaceholderText("Email"), "a@b.c");
    fireEvent.changeText(getByPlaceholderText("Mot de passe"), "pw");
    await act(async () => {
      fireEvent.press(getByText("Se connecter"));
    });
    await findByText(/Connexion impossible hors-ligne/);
  });

  it("Retour en ligne apres session offline tokenless -> invite a se reconnecter", async () => {
    const { ApiClientError } = apiClient as unknown as {
      ApiClientError: new (m: string, s?: number) => Error;
    };
    const alertSpy = jest.spyOn(Alert, "alert").mockImplementation(() => {});
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    login.mockRejectedValue(new ApiClientError("Network error"));
    verifyOfflineCred.mockResolvedValue(user);
    const { findByText, getByPlaceholderText, getByText } = render(<App />);
    await findByText("Se connecter");
    fireEvent.changeText(getByPlaceholderText("Email"), "a@b.c");
    fireEvent.changeText(getByPlaceholderText("Mot de passe"), "pw");
    await act(async () => {
      fireEvent.press(getByText("Se connecter"));
    });
    await findByText("Tableau de bord");
    // Le reseau revient -> prompt de reconnexion, PAS de sync silencieuse.
    await waitFor(() => expect(mockSyncState.listeners.length).toBeGreaterThan(0));
    await act(async () => {
      mockSyncState.listeners[0](true);
    });
    expect(alertSpy).toHaveBeenCalledWith(
      "De retour en ligne",
      expect.stringContaining("synchroniser"),
      expect.anything()
    );
    expect(syncOnReconnect).not.toHaveBeenCalled();
    alertSpy.mockRestore();
  });

  it("Login: meme compte qui se reconnecte -> NE purge PAS (file offline preservee)", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    (localDatabase.getLocalDbOwnerId as jest.Mock).mockResolvedValueOnce(1);
    login.mockResolvedValue({
      access_token: "acc",
      refresh_token: "ref",
      user,
      token_type: "Bearer",
      expires_in: 3600,
      refresh_expires_in: 86400,
    });
    const { findByText, getByPlaceholderText, getByText } = render(<App />);
    await findByText("Se connecter");
    fireEvent.changeText(getByPlaceholderText("Email"), "a@b.c");
    fireEvent.changeText(getByPlaceholderText("Mot de passe"), "pw");
    await act(async () => {
      fireEvent.press(getByText("Se connecter"));
    });
    await waitFor(() => expect(saveSession).toHaveBeenCalled());
    expect(clearDb).not.toHaveBeenCalled();
    expect(localDatabase.setLocalDbOwnerId).toHaveBeenCalledWith(1);
  });

  it("Login: onLogin throw Error -> affiche error", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    login.mockRejectedValue(new Error("creds bad"));
    const { findByText, getByPlaceholderText, getByText } = render(<App />);
    await findByText("Se connecter");
    fireEvent.changeText(getByPlaceholderText("Email"), "a@b.c");
    fireEvent.changeText(getByPlaceholderText("Mot de passe"), "pw");
    await act(async () => {
      fireEvent.press(getByText("Se connecter"));
    });
    await findByText("creds bad");
  });

  it("Login: onLogin throw non-Error -> fallback", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    login.mockRejectedValue("str-err");
    const { findByText, getByPlaceholderText, getByText } = render(<App />);
    await findByText("Se connecter");
    fireEvent.changeText(getByPlaceholderText("Email"), "a@b.c");
    fireEvent.changeText(getByPlaceholderText("Mot de passe"), "pw");
    await act(async () => {
      fireEvent.press(getByText("Se connecter"));
    });
    await findByText("Erreur de connexion");
  });

  it("Login + loadInterventions throw non-Error -> fallback message", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    login.mockResolvedValue({
      access_token: "acc",
      refresh_token: "ref",
      user,
      token_type: "Bearer",
      expires_in: 3600,
      refresh_expires_in: 86400,
    });
    getMy.mockRejectedValue("str-err");
    const { findByText, getByPlaceholderText, getByText } = render(<App />);
    await findByText("Se connecter");
    fireEvent.changeText(getByPlaceholderText("Email"), "a@b.c");
    fireEvent.changeText(getByPlaceholderText("Mot de passe"), "pw");
    await act(async () => {
      fireEvent.press(getByText("Se connecter"));
    });
    await waitFor(() => expect(saveSession).toHaveBeenCalled());
  });

  it("Login: Changer d'instance -> alerte 'Changer d'instance'", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    const alertSpy = jest.spyOn(Alert, "alert").mockImplementation(() => {});
    const { findByText, getByText } = render(<App />);
    await findByText("Se connecter");
    fireEvent.press(getByText("Changer d'instance"));
    // onChangeInstance est async (lit la file offline avant l'alerte).
    await waitFor(() =>
      expect(alertSpy).toHaveBeenCalledWith(
        "Changer d'instance",
        expect.any(String),
        expect.any(Array)
      )
    );
    alertSpy.mockRestore();
  });

  it("Alert Changer d'instance -> Confirmer clear tout", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    let confirmCallback: (() => void) | null = null;
    const alertSpy = jest.spyOn(Alert, "alert").mockImplementation((_t, _m, buttons) => {
      const confirm = (buttons as Array<{ text: string; onPress?: () => void }>).find(
        (b) => b.text === "Changer quand meme"
      );
      confirmCallback = confirm?.onPress ?? null;
    });
    const { findByText, getByText } = render(<App />);
    await findByText("Se connecter");
    fireEvent.press(getByText("Changer d'instance"));
    // onChangeInstance est async (lit la file offline avant l'alerte) : on
    // attend que l'alerte soit declenchee avant d'invoquer le callback.
    await waitFor(() => expect(confirmCallback).not.toBeNull());
    await act(async () => {
      confirmCallback?.();
    });
    await waitFor(() => expect(clearDb).toHaveBeenCalled());
    alertSpy.mockRestore();
  });

  it("Onboarding: scan QR avec JSON valide -> applique env + email + invite", async () => {
    setEnvBaseUrl.mockResolvedValue("https://api.test");
    fetchConfig.mockResolvedValue(config);
    saveInvite.mockResolvedValue(undefined);
    const { findByText } = render(<App />);
    await findByText(/Renseigne l'URL/);
    await act(async () => {
      fireEvent.press(await findByText("Scanner QR"));
    });
    await act(async () => {
      await mockCameraScan.lastOnBarcode?.({
        data: JSON.stringify({
          url: "https://api.test",
          invite_token: "TOK",
          email: "a@b.c",
        }),
      });
    });
    await findByText("Se connecter");
  });

  it("Onboarding: scan QR JSON avec base_url -> pris aussi", async () => {
    setEnvBaseUrl.mockResolvedValue("https://api.test");
    fetchConfig.mockResolvedValue(config);
    const { findByText } = render(<App />);
    await findByText(/Renseigne l'URL/);
    await act(async () => {
      fireEvent.press(await findByText("Scanner QR"));
    });
    await act(async () => {
      await mockCameraScan.lastOnBarcode?.({
        data: JSON.stringify({ base_url: "https://api.test", token: "TOK" }),
      });
    });
    await findByText("Se connecter");
  });

  it("Onboarding: scan QR JSON avec instance_url -> fallback", async () => {
    setEnvBaseUrl.mockResolvedValue("https://api.test");
    fetchConfig.mockResolvedValue(config);
    const { findByText } = render(<App />);
    await findByText(/Renseigne l'URL/);
    await act(async () => {
      fireEvent.press(await findByText("Scanner QR"));
    });
    await act(async () => {
      await mockCameraScan.lastOnBarcode?.({
        data: JSON.stringify({ instance_url: "https://api.test" }),
      });
    });
    await findByText("Se connecter");
  });

  it("Onboarding: scan URL simple avec params query", async () => {
    setEnvBaseUrl.mockResolvedValue("https://api.test");
    fetchConfig.mockResolvedValue(config);
    const { findByText } = render(<App />);
    await findByText(/Renseigne l'URL/);
    await act(async () => {
      fireEvent.press(await findByText("Scanner QR"));
    });
    await act(async () => {
      await mockCameraScan.lastOnBarcode?.({
        data: "https://api.test?invite_token=T&email=e",
      });
    });
    await findByText("Se connecter");
  });

  it("Onboarding: scan URL base_url en query", async () => {
    setEnvBaseUrl.mockResolvedValue("https://api.test");
    fetchConfig.mockResolvedValue(config);
    const { findByText } = render(<App />);
    await findByText(/Renseigne l'URL/);
    await act(async () => {
      fireEvent.press(await findByText("Scanner QR"));
    });
    await act(async () => {
      await mockCameraScan.lastOnBarcode?.({
        data: "https://whatever?base_url=https://api.test",
      });
    });
    await findByText("Se connecter");
  });

  it("Onboarding: scan payload vide -> erreur", async () => {
    const { findByText } = render(<App />);
    await findByText(/Renseigne l'URL/);
    await act(async () => {
      fireEvent.press(await findByText("Scanner QR"));
    });
    await act(async () => {
      await mockCameraScan.lastOnBarcode?.({ data: "" });
    });
    await findByText(/QR invalide: URL d'instance introuvable/);
  });

  it("Onboarding: scan payload non-parsable -> erreur", async () => {
    const { findByText } = render(<App />);
    await findByText(/Renseigne l'URL/);
    await act(async () => {
      fireEvent.press(await findByText("Scanner QR"));
    });
    await act(async () => {
      await mockCameraScan.lastOnBarcode?.({ data: "not-a-url-or-json" });
    });
    await findByText(/QR invalide/);
  });

  it("Dashboard: Deconnexion appelle onLogout", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockResolvedValue(user);
    getMy.mockResolvedValue({ items: [], count: 0, technicien_id: 0, date_debut: "", date_fin: "" });
    const { findByText } = render(<App />);
    await findByText("Deconnexion");
    await act(async () => {
      fireEvent.press(await findByText("Deconnexion"));
    });
    await waitFor(() => expect(clearSession).toHaveBeenCalled());
  });

  it("Dashboard: onLogout avec refreshToken appelle logout", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession
      .mockResolvedValueOnce({ accessToken: "t", refreshToken: "r", user })
      .mockResolvedValueOnce({ accessToken: "t", refreshToken: "r", user });
    me.mockResolvedValue(user);
    getMy.mockResolvedValue({ items: [], count: 0, technicien_id: 0, date_debut: "", date_fin: "" });
    logout.mockResolvedValue(undefined);
    const { findByText } = render(<App />);
    await findByText("Deconnexion");
    await act(async () => {
      fireEvent.press(await findByText("Deconnexion"));
    });
    await waitFor(() => expect(logout).toHaveBeenCalledWith("r"));
  });

  it("Dashboard: logout API throw -> ignore, continue clear", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession
      .mockResolvedValueOnce({ accessToken: "t", refreshToken: "r", user })
      .mockRejectedValueOnce(new Error("network"));
    me.mockResolvedValue(user);
    getMy.mockResolvedValue({ items: [], count: 0, technicien_id: 0, date_debut: "", date_fin: "" });
    const { findByText } = render(<App />);
    await findByText("Deconnexion");
    await act(async () => {
      fireEvent.press(await findByText("Deconnexion"));
    });
    await waitFor(() => expect(clearSession).toHaveBeenCalled());
  });

  it("Dashboard: Rafraichir appelle loadInterventions et set message", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockResolvedValue(user);
    getMy.mockResolvedValue({ items: [], count: 7, technicien_id: 0, date_debut: "", date_fin: "" });
    const { findByText } = render(<App />);
    await findByText(/À faire/);
    getMy.mockClear();
    await act(async () => {
      fireEvent.press(await findByText("Rafraichir les missions"));
    });
    expect(getMy).toHaveBeenCalled();
  });

  it("Dashboard: onOpenDetail -> navigate vers MissionDetail", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockResolvedValue(user);
    getMy.mockResolvedValue({
      items: [{ id: 42, titre: "T", statut: "planifiee" }],
      count: 1,
      technicien_id: 0,
      date_debut: "",
      date_fin: "",
    });
    // Le drain au boot (M3) relit le snapshot SQLite via getInterventionsSnapshot
    // puis setInterventions(snapshot.items). En prod ce snapshot reflete la meme
    // donnee que la liste chargee ; le mock doit donc etre coherent (sinon il
    // viderait la liste et "Voir le detail mission" disparaitrait).
    (localDatabase.getInterventionsSnapshot as jest.Mock).mockResolvedValue({
      items: [{ id: 42, titre: "T", statut: "planifiee" }],
    });
    const { findByText } = render(<App />);
    await findByText("T");
    await act(async () => {
      fireEvent.press(await findByText("Voir le detail mission"));
    });
    expect(mockNavigate).toHaveBeenCalledWith(
      "MissionDetail",
      expect.objectContaining({ interventionId: 42 })
    );
  });

  it("Network callback online=true -> syncOnReconnect + status", async () => {
    // syncOnReconnect est garde par userRef.current : il faut une session ouverte.
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockResolvedValue(user);
    syncOnReconnect.mockResolvedValue({ pushed: 2, failed: 0, pulled: 3, dead: 0 });
    const { findByText } = render(<App />);
    await findByText("Tableau de bord");
    await waitFor(() => expect(mockSyncState.listeners.length).toBeGreaterThan(0));
    await act(async () => {
      mockSyncState.listeners[0](true);
    });
    await waitFor(() => expect(syncOnReconnect).toHaveBeenCalled());
  });

  it("Network callback online=false -> pas de sync", async () => {
    render(<App />);
    await waitFor(() => expect(mockSyncState.listeners.length).toBeGreaterThan(0));
    await act(async () => {
      mockSyncState.listeners[0](false);
    });
    expect(syncOnReconnect).not.toHaveBeenCalled();
  });

  it("M4 : actions 'dead' -> avertissement affiche au technicien", async () => {
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockResolvedValue(user);
    syncOnReconnect.mockResolvedValue({ pushed: 1, failed: 0, pulled: 0, dead: 2 });
    const { findByText } = render(<App />);
    await findByText("Tableau de bord");
    await waitFor(() => expect(mockSyncState.listeners.length).toBeGreaterThan(0));
    await act(async () => {
      mockSyncState.listeners[0](true);
    });
    await findByText(/n'ont pas pu être synchronisées/);
  });

  it("Network callback avec 0 push/pull -> pas de loadInterventions", async () => {
    // syncOnReconnect est garde par userRef.current : il faut une session ouverte.
    getEnvBaseUrl.mockResolvedValue("https://api.test");
    getConfigFor.mockResolvedValue({ base_url: "https://api.test", config });
    readSession.mockResolvedValue({ accessToken: "t", refreshToken: "r", user });
    me.mockResolvedValue(user);
    syncOnReconnect.mockResolvedValue({ pushed: 0, failed: 0, pulled: 0, dead: 0 });
    const { findByText } = render(<App />);
    await findByText("Tableau de bord");
    await waitFor(() => expect(mockSyncState.listeners.length).toBeGreaterThan(0));
    await act(async () => {
      mockSyncState.listeners[0](true);
    });
    await waitFor(() => expect(syncOnReconnect).toHaveBeenCalled());
  });
});
