import "dart:convert";
import "package:http/http.dart" as http;

import "models.dart";
import "servers.dart";

/// Thrown when the API Server returns a non-2xx response.
class ApiException implements Exception {
  final int status;
  final String message;
  ApiException(this.status, this.message);
  @override
  String toString() => message;
}

/// One request's destination: the server that was active when it went out, its
/// base URL and its token. Pinning all three up front is what keeps a rejection
/// attributable — re-reading the active server on the way back would let one
/// server's 401 clear a different server's session when a switch lands between
/// a call going out and its answer arriving.
class _Target {
  final String id;
  final String base;
  final String? token;
  const _Target(this.id, this.base, this.token);

  Uri uri(String path) => Uri.parse("$base$path");

  Map<String, String> get authHeaders =>
      {if (token != null) "Authorization": "Bearer $token"};

  Map<String, String> get jsonHeaders =>
      {"Content-Type": "application/json", ...authHeaders};
}

/// The single client for the Car Control API Server. Which server the calls go
/// to is [serverRegistry]'s business: the base URL and the bearer token are both
/// read from whichever server is active, resolved fresh on every request so
/// switching takes effect without rebuilding the client. On 401 it calls
/// [onUnauthorized] with the server that rejected the session.
class ApiClient {
  void Function(String serverId)? onUnauthorized;

  /// The active server's token — read by the multipart and download helpers,
  /// which build their own requests.
  String? get token => serverRegistry.activeToken;

  /// The base URL the next request will go to.
  String get baseUrl => serverRegistry.activeBase;

  _Target _target() => _Target(
        serverRegistry.activeId,
        serverRegistry.activeBase,
        serverRegistry.activeToken,
      );

  Future<dynamic> _send(String method, String path, {Object? body}) async {
    final target = _target();
    final req = http.Request(method, target.uri(path))..headers.addAll(target.jsonHeaders);
    if (body != null) req.body = jsonEncode(body);
    final streamed = await http.Client().send(req);
    final res = await http.Response.fromStream(streamed);

    if (res.statusCode == 401 && path != "/auth/login") {
      onUnauthorized?.call(target.id);
      throw ApiException(401, "Session expired — please log in again.");
    }
    if (res.statusCode == 204 || res.body.isEmpty) return null;

    final data = jsonDecode(res.body);
    if (res.statusCode < 200 || res.statusCode >= 300) {
      throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase));
    }
    return data;
  }

  /// Digs a human-readable message out of the error shapes in play: this
  /// server's {error}, and PocketBase's {message, data:{field:{message}}} —
  /// which the user endpoints relay verbatim, so a duplicate email arrives as a
  /// per-field error rather than a flat string.
  String _errorMessage(dynamic data, String? fallback) {
    if (data is! Map) return fallback ?? "Request failed";
    if (data["error"] != null) return data["error"].toString();

    final fields = data["data"];
    if (fields is Map && fields.isNotEmpty) {
      final parts = fields.entries.map((e) {
        final v = e.value;
        final msg = v is Map && v["message"] != null ? v["message"] : v;
        return "${e.key}: $msg";
      });
      return parts.join("; ");
    }
    if (data["message"] != null) return data["message"].toString();
    return fallback ?? "Request failed";
  }

  // --- auth ---
  /// Signs in against a named base rather than the active server: the add-server
  /// sheet checks credentials against the server being added before anything
  /// switches to it, so a wrong password leaves you where you were.
  ///
  /// The API Server proxies login to PocketBase and relays its response
  /// verbatim, so the user arrives under `record` (PocketBase's name) and the
  /// token is PocketBase's own — the server no longer mints its own JWT.
  Future<(String, AuthUser)> loginAt(String base, String email, String password) async {
    final res = await http.post(
      Uri.parse("$base/auth/login"),
      headers: const {"Content-Type": "application/json"},
      body: jsonEncode({"email": email, "password": password}),
    );
    final data = res.body.isEmpty ? null : _tryDecode(res.body);
    if (res.statusCode < 200 || res.statusCode >= 300) {
      throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase));
    }
    return (data["token"] as String, AuthUser.fromJson(Map<String, dynamic>.from(data["record"])));
  }

  Future<(String, AuthUser)> login(String email, String password) =>
      loginAt(serverRegistry.activeBase, email, password);

  // --- cars ---
  Future<List<Car>> listCars() async {
    final data = await _send("GET", "/cars") as List;
    return data.map((e) => Car.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<Car> getCar(String id) async {
    final data = await _send("GET", "/cars/$id");
    return Car.fromJson(Map<String, dynamic>.from(data));
  }

  Future<Car> createCar(Map<String, dynamic> body) async {
    final data = await _send("POST", "/cars", body: body);
    return Car.fromJson(Map<String, dynamic>.from(data));
  }

  Future<Car> updateCar(String id, Map<String, dynamic> body) async {
    final data = await _send("PATCH", "/cars/$id", body: body);
    return Car.fromJson(Map<String, dynamic>.from(data));
  }

  Future<void> deleteCar(String id) => _send("DELETE", "/cars/$id");

  /// What this car's page shows and in which order — its own endpoint rather
  /// than part of the car PATCH, so saving the car form can never silently
  /// reveal a hidden tab or undo an arrangement. Every field is optional; only
  /// the ones passed are written.
  Future<Car> updateCarView(
    String id, {
    List<String>? hiddenTabs,
    List<String>? hiddenFields,
    List<String>? tabOrder,
    List<String>? fieldOrder,
    List<String>? metricOrder,
    List<String>? hiddenServiceColumns,
    List<String>? serviceColumnOrder,
    List<String>? hiddenServiceParts,
  }) async {
    final body = <String, dynamic>{
      if (hiddenTabs != null) "hiddenTabs": hiddenTabs,
      if (hiddenFields != null) "hiddenFields": hiddenFields,
      if (tabOrder != null) "tabOrder": tabOrder,
      if (fieldOrder != null) "fieldOrder": fieldOrder,
      if (metricOrder != null) "metricOrder": metricOrder,
      if (hiddenServiceColumns != null) "hiddenServiceColumns": hiddenServiceColumns,
      if (serviceColumnOrder != null) "serviceColumnOrder": serviceColumnOrder,
      if (hiddenServiceParts != null) "hiddenServiceParts": hiddenServiceParts,
    };
    final data = await _send("PUT", "/cars/$id/view", body: body);
    return Car.fromJson(Map<String, dynamic>.from(data));
  }

  // --- sharing (owner-only) ---
  Future<List<CarShare>> listCarShares(String carId) async {
    final data = await _send("GET", "/cars/$carId/shares") as List;
    return data.map((e) => CarShare.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<CarShare> addCarShare(String carId, String email, String permission) async {
    final data = await _send("POST", "/cars/$carId/shares",
        body: {"email": email, "permission": permission});
    return CarShare.fromJson(Map<String, dynamic>.from(data));
  }

  Future<void> removeCarShare(String carId, String userId) =>
      _send("DELETE", "/cars/$carId/shares/$userId");

  // --- user management (admin or superadmin) ---
  // Admins are scoped by the server to their own organization; superadmins see
  // everyone. Responses are enveloped ({users}/{user}).
  Future<List<AdminUser>> listUsers() async {
    final data = await _send("GET", "/users");
    final items = (data["users"] ?? []) as List;
    return items.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  /// Creates a user. [organization] is the superadmin's choice of tenant — an
  /// empty string deliberately means "no organization". Omit it entirely for an
  /// admin: the server forces its own org on their members, so sending anything
  /// would be noise the server ignores.
  Future<AdminUser> createUser({
    required String email,
    required String password,
    String? name,
    String role = "user",
    String? organization,
  }) async {
    final data = await _send("POST", "/users", body: {
      "email": email,
      "password": password,
      "name": name ?? "",
      "role": role,
      if (organization != null) "organization": organization,
    });
    return AdminUser.fromJson(Map<String, dynamic>.from(data["user"]));
  }

  Future<AdminUser> updateUser(String id, {String? name, String? role}) async {
    final body = <String, dynamic>{};
    if (name != null) body["name"] = name;
    if (role != null) body["role"] = role;
    final data = await _send("PATCH", "/users/$id", body: body);
    return AdminUser.fromJson(Map<String, dynamic>.from(data["user"]));
  }

  /// Password resets are a field on the user PATCH now, not a separate endpoint.
  Future<void> setUserPassword(String id, String newPassword) =>
      _send("PATCH", "/users/$id", body: {"password": newPassword});

  Future<void> deleteUser(String id) => _send("DELETE", "/users/$id");

  // --- organizations ---
  // Listing is manager-only (an admin sees just their own org), but creating is
  // open to any user who has none — the creator becomes that org's admin in the
  // same request. Renames and deletes are scoped to the caller's own org unless
  // they are a superadmin. Responses are enveloped ({organizations}/{organization}).
  Future<List<Organization>> listOrgs() async {
    final data = await _send("GET", "/orgs");
    final items = (data["organizations"] ?? []) as List;
    return items.map((e) => Organization.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<Organization> createOrg(String name) async {
    final data = await _send("POST", "/orgs", body: {"name": name});
    return Organization.fromJson(Map<String, dynamic>.from(data["organization"]));
  }

  Future<Organization> renameOrg(String id, String name) async {
    final data = await _send("PATCH", "/orgs/$id", body: {"name": name});
    return Organization.fromJson(Map<String, dynamic>.from(data["organization"]));
  }

  Future<void> deleteOrg(String id) => _send("DELETE", "/orgs/$id");

  // --- service records ---
  Future<List<ServiceRecord>> listCarServices(String carId) async {
    final data = await _send("GET", "/cars/$carId/service-records") as List;
    return data.map((e) => ServiceRecord.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<ServiceRecord> createService(Map<String, dynamic> body) async {
    final data = await _send("POST", "/service-records", body: body);
    return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
  }

  Future<ServiceRecord> updateService(String id, Map<String, dynamic> body) async {
    final data = await _send("PATCH", "/service-records/$id", body: body);
    return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
  }

  Future<void> deleteService(String id) => _send("DELETE", "/service-records/$id");

  // --- parts ---
  Future<List<Part>> listCarParts(String carId) async {
    final data = await _send("GET", "/cars/$carId/parts") as List;
    return data.map((e) => Part.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<Part> createPart(Map<String, dynamic> body) async {
    final data = await _send("POST", "/parts", body: body);
    return Part.fromJson(Map<String, dynamic>.from(data));
  }

  Future<Part> updatePart(String id, Map<String, dynamic> body) async {
    final data = await _send("PATCH", "/parts/$id", body: body);
    return Part.fromJson(Map<String, dynamic>.from(data));
  }

  Future<void> deletePart(String id) => _send("DELETE", "/parts/$id");

  // --- technical checks ---
  Future<List<TechnicalCheck>> listCarTechnicalChecks(String carId) async {
    final data = await _send("GET", "/cars/$carId/technical-checks") as List;
    return data.map((e) => TechnicalCheck.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<TechnicalCheck> createTechnicalCheck(Map<String, dynamic> body) async {
    final data = await _send("POST", "/technical-checks", body: body);
    return TechnicalCheck.fromJson(Map<String, dynamic>.from(data));
  }

  Future<TechnicalCheck> updateTechnicalCheck(String id, Map<String, dynamic> body) async {
    final data = await _send("PATCH", "/technical-checks/$id", body: body);
    return TechnicalCheck.fromJson(Map<String, dynamic>.from(data));
  }

  Future<void> deleteTechnicalCheck(String id) => _send("DELETE", "/technical-checks/$id");

  // --- fuel ---
  Future<List<FuelEntry>> listCarFuelEntries(String carId) async {
    final data = await _send("GET", "/cars/$carId/fuel-entries") as List;
    return data.map((e) => FuelEntry.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<FuelStats> getCarFuelStats(String carId) async {
    final data = await _send("GET", "/cars/$carId/fuel-stats");
    return FuelStats.fromJson(Map<String, dynamic>.from(data));
  }

  Future<FuelEntry> createFuelEntry(Map<String, dynamic> body) async {
    final data = await _send("POST", "/fuel-entries", body: body);
    return FuelEntry.fromJson(Map<String, dynamic>.from(data));
  }

  Future<FuelEntry> updateFuelEntry(String id, Map<String, dynamic> body) async {
    final data = await _send("PATCH", "/fuel-entries/$id", body: body);
    return FuelEntry.fromJson(Map<String, dynamic>.from(data));
  }

  Future<void> deleteFuelEntry(String id) => _send("DELETE", "/fuel-entries/$id");

  // --- charging sessions ---
  // The electric counterpart of the fuel entries, on identical terms: the
  // per-car list plus the derived summary, and CRUD on the flat collection.
  Future<List<ChargingSession>> listCarChargingSessions(String carId) async {
    final data = await _send("GET", "/cars/$carId/charging-sessions") as List;
    return data.map((e) => ChargingSession.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<ChargingStats> getCarChargingStats(String carId) async {
    final data = await _send("GET", "/cars/$carId/charging-stats");
    return ChargingStats.fromJson(Map<String, dynamic>.from(data));
  }

  Future<ChargingSession> createChargingSession(Map<String, dynamic> body) async {
    final data = await _send("POST", "/charging-sessions", body: body);
    return ChargingSession.fromJson(Map<String, dynamic>.from(data));
  }

  Future<ChargingSession> updateChargingSession(String id, Map<String, dynamic> body) async {
    final data = await _send("PATCH", "/charging-sessions/$id", body: body);
    return ChargingSession.fromJson(Map<String, dynamic>.from(data));
  }

  Future<void> deleteChargingSession(String id) => _send("DELETE", "/charging-sessions/$id");

  // --- maintenance ---
  Future<List<MaintenanceEntry>> listCarMaintenance(String carId) async {
    final data = await _send("GET", "/cars/$carId/maintenance") as List;
    return data.map((e) => MaintenanceEntry.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<MaintenanceEntry> createMaintenance(Map<String, dynamic> body) async {
    final data = await _send("POST", "/maintenance", body: body);
    return MaintenanceEntry.fromJson(Map<String, dynamic>.from(data));
  }

  Future<MaintenanceEntry> updateMaintenance(String id, Map<String, dynamic> body) async {
    final data = await _send("PATCH", "/maintenance/$id", body: body);
    return MaintenanceEntry.fromJson(Map<String, dynamic>.from(data));
  }

  Future<void> deleteMaintenance(String id) => _send("DELETE", "/maintenance/$id");

  // --- documents ---
  // The path is /car-documents so it can't be mistaken for the user-facing
  // account documents other Vault services expose.
  Future<List<CarDocument>> listCarDocuments(String carId) async {
    final data = await _send("GET", "/cars/$carId/documents") as List;
    return data.map((e) => CarDocument.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<CarDocument> createDocument(Map<String, dynamic> body) async {
    final data = await _send("POST", "/car-documents", body: body);
    return CarDocument.fromJson(Map<String, dynamic>.from(data));
  }

  Future<CarDocument> updateDocument(String id, Map<String, dynamic> body) async {
    final data = await _send("PATCH", "/car-documents/$id", body: body);
    return CarDocument.fromJson(Map<String, dynamic>.from(data));
  }

  Future<void> deleteDocument(String id) => _send("DELETE", "/car-documents/$id");

  // --- reminders ---
  Future<List<Reminder>> listCarReminders(String carId) async {
    final data = await _send("GET", "/cars/$carId/reminders") as List;
    return data.map((e) => Reminder.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<Reminder> createReminder(Map<String, dynamic> body) async {
    final data = await _send("POST", "/reminders", body: body);
    return Reminder.fromJson(Map<String, dynamic>.from(data));
  }

  Future<Reminder> updateReminder(String id, Map<String, dynamic> body) async {
    final data = await _send("PATCH", "/reminders/$id", body: body);
    return Reminder.fromJson(Map<String, dynamic>.from(data));
  }

  Future<void> deleteReminder(String id) => _send("DELETE", "/reminders/$id");

  /// Completing a recurring reminder rolls its trigger forward instead of
  /// closing it out; the server decides which, so the caller just re-reads.
  Future<Reminder> completeReminder(String id) async {
    final data = await _send("POST", "/reminders/$id/complete");
    return Reminder.fromJson(Map<String, dynamic>.from(data));
  }

  // --- attachments ---
  // Every attachable collection takes a file on identical terms, so one set of
  // helpers is parameterized by the collection's path rather than repeated six
  // times. See the API's attachments.go.

  /// Uploads (or replaces) a record's file. Returns the re-read record JSON, so
  /// the caller decodes it into whichever model it owns.
  Future<Map<String, dynamic>> uploadAttachment(
      String path, String id, List<int> bytes, String filename) async {
    final target = _target();
    final req = http.MultipartRequest("POST", target.uri("$path/$id/file"))
      ..headers.addAll(target.authHeaders);
    req.files.add(http.MultipartFile.fromBytes("file", bytes, filename: filename));
    final res = await http.Response.fromStream(await req.send());
    if (res.statusCode == 401) {
      onUnauthorized?.call(target.id);
      throw ApiException(401, "Session expired — please log in again.");
    }
    final data = jsonDecode(res.body);
    if (res.statusCode < 200 || res.statusCode >= 300) {
      throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase));
    }
    return Map<String, dynamic>.from(data);
  }

  /// The attachment's bytes, or null when there is no file. Never a public URL —
  /// the server re-checks car access on every fetch.
  Future<List<int>?> getAttachmentBytes(String path, String id) async {
    final target = _target();
    final res = await http.get(target.uri("$path/$id/file"), headers: target.authHeaders);
    if (res.statusCode == 200) return res.bodyBytes;
    return null;
  }

  Future<void> deleteAttachment(String path, String id) => _send("DELETE", "$path/$id/file");

  // --- settings: profile / account ---
  Future<UserProfile> getMe() async {
    final data = await _send("GET", "/me");
    return UserProfile.fromJson(Map<String, dynamic>.from(data));
  }

  Future<UserProfile> updateMe(Map<String, dynamic> patch) async {
    final data = await _send("PATCH", "/me", body: patch);
    return UserProfile.fromJson(Map<String, dynamic>.from(data));
  }

  Future<void> changePassword(String oldPassword, String newPassword) => _send(
        "POST",
        "/me/password",
        body: {"oldPassword": oldPassword, "newPassword": newPassword},
      );

  Future<void> requestVerification() => _send("POST", "/me/verify/request");

  // --- settings: avatar ---
  Future<UserProfile> uploadAvatar(List<int> bytes, String filename) async {
    final target = _target();
    final req = http.MultipartRequest("POST", target.uri("/me/avatar"))
      ..headers.addAll(target.authHeaders);
    req.files.add(http.MultipartFile.fromBytes("avatar", bytes, filename: filename));
    final res = await http.Response.fromStream(await req.send());
    if (res.statusCode == 401) {
      onUnauthorized?.call(target.id);
      throw ApiException(401, "Session expired — please log in again.");
    }
    final data = jsonDecode(res.body);
    if (res.statusCode < 200 || res.statusCode >= 300) {
      final msg = data is Map && data["error"] != null ? data["error"].toString() : res.reasonPhrase;
      throw ApiException(res.statusCode, msg ?? "Upload failed");
    }
    return UserProfile.fromJson(Map<String, dynamic>.from(data));
  }

  Future<List<int>?> getAvatarBytes() async {
    final target = _target();
    final res = await http.get(target.uri("/me/avatar"), headers: target.authHeaders);
    if (res.statusCode == 200) return res.bodyBytes;
    return null;
  }

  Future<void> deleteAvatar() => _send("DELETE", "/me/avatar");

  // --- settings: account deletion ---
  Future<DateTime?> requestAccountDeletion(String confirmEmail) async {
    final data = await _send("POST", "/me/delete", body: {"confirmEmail": confirmEmail});
    final at = (data is Map) ? data["eligibleAt"] : null;
    return at == null ? null : DateTime.tryParse(at.toString());
  }

  Future<void> cancelAccountDeletion() => _send("POST", "/me/delete/cancel");
  Future<void> finalizeAccountDeletion() => _send("DELETE", "/me");

  // --- data export / import ---

  /// The whole account as a JSON file: the profile plus every car the user owns
  /// with its service records and parts. Cars merely shared with them are not
  /// included. Returns the bytes and the filename the server named it, which is
  /// dated — the phone has to write the file itself, so it needs both.
  Future<(List<int>, String)> exportData() async {
    final target = _target();
    final res = await http.get(target.uri("/me/export"), headers: target.authHeaders);
    if (res.statusCode == 401) {
      onUnauthorized?.call(target.id);
      throw ApiException(401, "Session expired — please log in again.");
    }
    if (res.statusCode < 200 || res.statusCode >= 300) {
      throw ApiException(res.statusCode, _errorMessage(_tryDecode(res.body), res.reasonPhrase));
    }
    return (res.bodyBytes, _filenameFrom(res.headers["content-disposition"]));
  }

  /// Adds the cars in a previously exported file. Always creates new records —
  /// nothing is merged with or overwritten, so importing the same file twice
  /// leaves two copies rather than one updated one.
  Future<ImportResult> importData(Map<String, dynamic> payload) async {
    final data = await _send("POST", "/me/import", body: payload);
    return ImportResult.fromJson(Map<String, dynamic>.from(data));
  }

  static dynamic _tryDecode(String body) {
    try {
      return jsonDecode(body);
    } catch (_) {
      return null;
    }
  }

  /// The filename out of `attachment; filename="…"`, falling back to a plain
  /// name so the export is still saveable if the header is missing or unquoted.
  static String _filenameFrom(String? disposition) {
    if (disposition == null) return "drivervault-export.json";
    final m = RegExp(r'filename="?([^";]+)"?').firstMatch(disposition);
    final name = m?.group(1)?.trim() ?? "";
    return name.isEmpty ? "drivervault-export.json" : name;
  }

  // --- vehicle providers (the connected-service tab) ---
  // Every call runs server-side under *this* user's manufacturer account, so a
  // car shared from someone else only shows provider data when that vehicle is
  // on this user's account too. A closed gate is a 200 with `unavailable` set
  // rather than an error: "we asked, and here is why there is nothing".

  Future<List<VehicleProvider>> listVehicleProviders() async {
    final data = await _send("GET", "/vehicle-providers");
    final items = (data["providers"] ?? []) as List;
    return items.map((e) => VehicleProvider.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<List<ProviderVehicle>> listProviderVehicles(String provider) async {
    final data = await _send("GET", "/vehicle-providers/${Uri.encodeComponent(provider)}/vehicles");
    final items = (data["vehicles"] ?? []) as List;
    return items.map((e) => ProviderVehicle.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  Future<ProviderSnapshot> getCarProvider(String carId) async {
    final data = await _send("GET", "/cars/$carId/provider");
    return ProviderSnapshot.fromJson(Map<String, dynamic>.from(data));
  }

  /// Links this car to a vehicle on the caller's provider account; an empty
  /// [provider] unlinks it. Returns the updated car.
  Future<Car> linkCarProvider(String carId, {String provider = "", String vehicleId = ""}) async {
    final data = await _send("POST", "/cars/$carId/provider",
        body: {"provider": provider, "vehicleId": vehicleId});
    return Car.fromJson(Map<String, dynamic>.from(data));
  }

  /// Re-applies the provider's data to the car. [include] selects what to take
  /// (identity, fuelType, dates, odometer); omitted means everything.
  Future<Car> syncCarProvider(String carId, {Map<String, bool>? include}) async {
    final data = await _send("POST", "/cars/$carId/provider/sync",
        body: {if (include != null) "include": include});
    final car = (data is Map && data["car"] != null) ? data["car"] : data;
    return Car.fromJson(Map<String, dynamic>.from(car));
  }

  // --- integrations (per-user plugin settings, superadmin → org → user cascade) ---
  // Each connector has the same trio: get… returns the resolved view
  // (effective/own/locked per field, secrets and inherited values masked); save…
  // writes the caller's editable layer (scope "user" by default, "org" for org
  // admins); test… runs a live probe under the resolved config.
  Future<IntegrationView> getToyota() async {
    final data = await _send("GET", "/integrations/toyota");
    return IntegrationView.fromJson(Map<String, dynamic>.from(data));
  }

  Future<IntegrationView> saveToyota(Map<String, dynamic> body) async {
    final data = await _send("PUT", "/integrations/toyota", body: body);
    return IntegrationView.fromJson(Map<String, dynamic>.from(data));
  }

  Future<IntegrationHealth> testToyota() async {
    final data = await _send("POST", "/integrations/toyota/health");
    final h = (data is Map ? data["health"] : null) ?? {};
    return IntegrationHealth.fromJson(Map<String, dynamic>.from(h));
  }

  Future<IntegrationView> getAnkerSolix() async {
    final data = await _send("GET", "/integrations/anker-solix");
    return IntegrationView.fromJson(Map<String, dynamic>.from(data));
  }

  Future<IntegrationView> saveAnkerSolix(Map<String, dynamic> body) async {
    final data = await _send("PUT", "/integrations/anker-solix", body: body);
    return IntegrationView.fromJson(Map<String, dynamic>.from(data));
  }

  Future<IntegrationHealth> testAnkerSolix() async {
    final data = await _send("POST", "/integrations/anker-solix/health");
    final h = (data is Map ? data["health"] : null) ?? {};
    return IntegrationHealth.fromJson(Map<String, dynamic>.from(h));
  }

  /// The chargers on the linked Anker account, fetched server-side under the
  /// resolved credentials. A closed gate answers 200 with an empty list and the
  /// reason in `detail`, so this is not an error path.
  Future<AnkerChargerList> listAnkerChargers() async {
    final data = await _send("GET", "/integrations/anker-solix/chargers");
    if (data is! Map) return const AnkerChargerList();
    return AnkerChargerList.fromJson(Map<String, dynamic>.from(data));
  }

  /// Every view the account holds about one charger — the station record, the
  /// totals, the history, the sessions, the OCPP backend, the cards, the
  /// sharing, the firmware and the rest, plus its site's views when it has a
  /// site — asked for one charger at a time, because none of those endpoints
  /// lists chargers.
  Future<ChargerDetails> getAnkerChargerDetails(String sn) async {
    final data = await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/details");
    if (data is! Map) return const ChargerDetails();
    return ChargerDetails.fromJson(Map<String, dynamic>.from(data));
  }

  // Greencell (HabuDen EV charger). Same cascade, but what resolves is an MQTT
  // broker rather than a cloud account — the charger publishes to a broker the
  // owner runs and the server joins it. testGreencell connects to that broker and
  // broadcasts for devices, so "degraded" means reachable-but-no-charger.
  Future<IntegrationView> getGreencell() async {
    final data = await _send("GET", "/integrations/greencell");
    return IntegrationView.fromJson(Map<String, dynamic>.from(data));
  }

  Future<IntegrationView> saveGreencell(Map<String, dynamic> body) async {
    final data = await _send("PUT", "/integrations/greencell", body: body);
    return IntegrationView.fromJson(Map<String, dynamic>.from(data));
  }

  Future<IntegrationHealth> testGreencell() async {
    final data = await _send("POST", "/integrations/greencell/health");
    final h = (data is Map ? data["health"] : null) ?? {};
    return IntegrationHealth.fromJson(Map<String, dynamic>.from(h));
  }

  // --- Anker Solix control (per charger) ---
  //
  // getAnkerControl returns the control mode, connection status and a live
  // status snapshot — an OCPP session snapshot in own/proxy mode, a Modbus
  // register snapshot in modbus mode.
  //
  // The two modes are provisioned differently, and each has its own pair here:
  // OCPP needs a token the operator installs into the charger (ankerControlToken
  // / ankerControlRevoke), Modbus needs the charger's address on the local
  // network (ankerControlAddress / ankerControlForgetAddress). A charger may
  // hold both; setting one leaves the other alone.
  String _sn(String sn) => Uri.encodeComponent(sn);

  Future<AnkerControl> getAnkerControl(String sn) async {
    final data = await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/control");
    return AnkerControl.fromJson(Map<String, dynamic>.from(data));
  }

  /// (Re)generates the per-charger control token; the plaintext token is returned
  /// exactly once, so the caller must show it immediately.
  Future<String> ankerControlToken(String sn) async {
    final data = await _send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/control/token");
    return (data is Map ? _asString(data["token"]) : "");
  }

  Future<void> ankerControlRevoke(String sn) =>
      _send("DELETE", "/integrations/anker-solix/chargers/${_sn(sn)}/control/token");

  /// Where the charger answers on the local network. Modbus dials the charger
  /// rather than waiting to be dialled, so this address is what the mode needs
  /// in place of a token.
  Future<void> ankerControlAddress(String sn, String host, int port) => _send(
        "PUT",
        "/integrations/anker-solix/chargers/${_sn(sn)}/control/address",
        body: {"host": host, "port": port},
      );

  Future<void> ankerControlForgetAddress(String sn) =>
      _send("DELETE", "/integrations/anker-solix/chargers/${_sn(sn)}/control/address");

  /// One control command. Over OCPP: start, stop, limit, clear-limit,
  /// availability, reset, unlock, trigger, config. Over Modbus TCP: start, stop,
  /// limit, boost, phase, timeout, status.
  Future<void> ankerControlAction(String sn, String action, [Map<String, dynamic> body = const {}]) =>
      _send("POST", "/integrations/anker-solix/chargers/${_sn(sn)}/$action", body: body);

  // --- charger providers and the user's own chargers ---
  //
  // The garage's import aimed at the wall: a charger on a connected service
  // becomes one of the caller's own home chargers, and stays one after the
  // account it came from is disconnected.

  /// Every charger service, each with whether the caller can use it and, when
  /// they cannot, the one sentence saying what to do about it.
  Future<List<ChargerProvider>> listChargerProviders() async {
    final data = await _send("GET", "/charger-providers");
    final items = (data["providers"] ?? []) as List;
    return items.map((e) => ChargerProvider.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  /// The chargers on that account, each annotated with the DriverVault charger
  /// it is already linked to.
  Future<ProviderChargerList> listProviderChargers(String provider) async {
    final data = await _send(
        "GET", "/charger-providers/${Uri.encodeComponent(provider)}/chargers");
    if (data is! Map) return const ProviderChargerList();
    return ProviderChargerList.fromJson(Map<String, dynamic>.from(data));
  }

  /// Creates a home charger from one on the account. An empty [name] takes
  /// whatever the service calls it.
  Future<HomeCharger> importProviderCharger(String provider, String chargerId,
      {String name = ""}) async {
    final data = await _send(
      "POST",
      "/charger-providers/${Uri.encodeComponent(provider)}/import",
      body: {"chargerId": chargerId, "name": name},
    );
    final c = (data is Map ? data["charger"] : null) ?? {};
    return HomeCharger.fromJson(Map<String, dynamic>.from(c));
  }

  Future<List<HomeCharger>> listHomeChargers() async {
    final data = await _send("GET", "/home-chargers");
    final items = (data["chargers"] ?? []) as List;
    return items.map((e) => HomeCharger.fromJson(Map<String, dynamic>.from(e))).toList();
  }

  /// Only the name is editable — everything else describes the hardware and
  /// comes from the service the charger was imported from.
  Future<HomeCharger> renameHomeCharger(String id, String name) async {
    final data = await _send("PATCH", "/home-chargers/${Uri.encodeComponent(id)}",
        body: {"name": name});
    final c = (data is Map ? data["charger"] : null) ?? {};
    return HomeCharger.fromJson(Map<String, dynamic>.from(c));
  }

  Future<void> deleteHomeCharger(String id) =>
      _send("DELETE", "/home-chargers/${Uri.encodeComponent(id)}");

  static String _asString(dynamic v) => v == null ? "" : v.toString();
}
