// Domain models mirroring the API Server JSON (which mirrors Car Service.xlsx).

int _asInt(dynamic v) => v is int ? v : (v is num ? v.toInt() : 0);
String _asStr(dynamic v) => v == null ? "" : v.toString();
bool _asBool(dynamic v) => v == true;
double _asDouble(dynamic v) => v is num ? v.toDouble() : 0.0;

/// Nullable variants for the derived fields the server omits when it could not
/// compute them. A missing consumption figure is not zero — it means "unknown",
/// and must stay distinguishable so the UI can render "—" instead of a
/// misleading 0.0.
int? _asIntOrNull(dynamic v) => v is num ? v.toInt() : null;
double? _asDoubleOrNull(dynamic v) => v is num ? v.toDouble() : null;
DateTime? _asDate(dynamic v) =>
    v == null ? null : DateTime.tryParse(v.toString())?.toLocal();

/// A JSON array of strings — the key sets a car carries for what its page shows
/// and in which order. Absent or null reads as empty, which every consumer takes
/// to mean "nothing hidden" / "the default order".
List<String> _asStrList(dynamic v) =>
    v is List ? v.map(_asStr).where((s) => s.isNotEmpty).toList() : const [];

/// A JSON object of plain values, read as strings — a service's own fields,
/// relayed under its own keys. A value that says nothing leaves no entry, so a
/// caller can take a key's presence to mean the service answered it.
Map<String, String> _asStrMap(dynamic v) {
  if (v is! Map) return const {};
  final out = <String, String>{};
  v.forEach((key, value) {
    final s = _asStr(value);
    if (s.isNotEmpty) out[key.toString()] = s;
  });
  return out;
}

/// The single optional file a record carries — a receipt, a scan, a photo of a
/// part's box. Mirrors the API's embedded Attachment: the bytes are never in the
/// JSON, only whether there are any and what they were stored as. Fetch them
/// from GET /{records}/{id}/file, which re-checks access per request.
mixin HasAttachment {
  String get fileName;
  bool get hasFile;
}

/// The server-computed lifecycle state of a dated document or certificate.
/// [days] is null when there is no expiry date at all.
class ExpiryAssessment {
  final String state; // no_expiry | valid | expiring_soon | expired
  final int? days;

  const ExpiryAssessment({this.state = "no_expiry", this.days});

  factory ExpiryAssessment.fromJson(Map<String, dynamic>? j) {
    if (j == null) return const ExpiryAssessment();
    return ExpiryAssessment(
      state: _asStr(j["state"]).isEmpty ? "no_expiry" : _asStr(j["state"]),
      days: _asIntOrNull(j["daysUntilExpiry"]),
    );
  }
}

class Car {
  final String id;
  final String name;
  final String make;
  final String model;
  final int year;
  final String registration;
  final String registrationCountry;
  final String vin;
  final String oilSpec;
  final String transmissionOilSpec;
  final String differentialOilSpec;
  final String brakeFluidSpec;
  final String coolantSpec;
  final String fuelType; // petrol | petrol_lpg | diesel | diesel_lpg | hybrid | electric | hydrogen
  // ISO 8601 reduced precision: "2015", "2015-03" or "2015-03-10". Entered on
  // the web, which can say how much of it is known; this app's date picker only
  // makes full ones, but it reads and preserves the rest. See formatPartialDate.
  final String buildDate;
  final String firstRegistrationDate; // ISO YYYY-MM-DD (date-only)
  final int serviceIntervalDays;
  final int serviceIntervalKm;

  /// The roadworthiness inspection cycle. Only prefills the next date — the
  /// interval is set by law rather than by the car, so any check can override it
  /// with the date its certificate actually carries.
  final int technicalCheckIntervalDays;
  final int currentKm;

  /// The requesting user's permission on this car: "owner", "write", or "read".
  /// The API sets it on every car read. Defaults to "owner" so older responses
  /// (or any code that constructs a Car without it) stay fully editable.
  final String access;

  /// The manufacturer service this car came from ("toyota") and that service's
  /// own id for the vehicle (the VIN, for Toyota). Both blank for a car entered
  /// by hand. Written only by the link/import endpoints, never by a car edit —
  /// which is why _carPayload leaves them out.
  final String provider;
  final String providerVehicleId;

  /// What this car's page does not show, and the order it lays out what it
  /// does. Properties of the car, so everyone it is shared with sees the same
  /// page. The hidden sets rather than the visible ones, so a tab or field added
  /// in a later release is on by default; the orders may be partial, and any key
  /// they leave out follows the ones they name. Written only through
  /// PUT /cars/{id}/view.
  final List<String> hiddenTabs;
  final List<String> hiddenFields;
  final List<String> tabOrder;
  final List<String> fieldOrder;
  final List<String> metricOrder;

  /// The columns of the Service history the car does not show, and the order it
  /// lays out the ones it does. The same rules as the lists above, with one
  /// difference: "date" is never in the hidden set — a service is the day it
  /// happened, and a history with the day taken out stops being a history — but
  /// it is in the order, because there is no reason it has to come first.
  final List<String> hiddenServiceColumns;
  final List<String> serviceColumnOrder;

  /// The parts this car's services never change, as part keys. They come off
  /// the service form and out of the history's chips together — a part nobody
  /// records is one nobody wants offered. No order beside it: the parts are a
  /// checkbox list inside one column, and their position says nothing.
  final List<String> hiddenServiceParts;

  Car({
    required this.id,
    required this.name,
    required this.make,
    required this.model,
    required this.year,
    required this.registration,
    this.registrationCountry = "",
    required this.vin,
    required this.oilSpec,
    required this.transmissionOilSpec,
    required this.differentialOilSpec,
    required this.brakeFluidSpec,
    required this.coolantSpec,
    this.fuelType = "",
    this.buildDate = "",
    this.firstRegistrationDate = "",
    required this.serviceIntervalDays,
    required this.serviceIntervalKm,
    this.technicalCheckIntervalDays = 0,
    required this.currentKm,
    this.access = "owner",
    this.provider = "",
    this.providerVehicleId = "",
    this.hiddenTabs = const [],
    this.hiddenFields = const [],
    this.tabOrder = const [],
    this.fieldOrder = const [],
    this.metricOrder = const [],
    this.hiddenServiceColumns = const [],
    this.serviceColumnOrder = const [],
    this.hiddenServiceParts = const [],
  });

  factory Car.fromJson(Map<String, dynamic> j) => Car(
        id: _asStr(j["id"]),
        name: _asStr(j["name"]),
        make: _asStr(j["make"]),
        model: _asStr(j["model"]),
        year: _asInt(j["year"]),
        registration: _asStr(j["registration"]),
        registrationCountry: _asStr(j["registrationCountry"]),
        vin: _asStr(j["vin"]),
        oilSpec: _asStr(j["oilSpec"]),
        transmissionOilSpec: _asStr(j["transmissionOilSpec"]),
        differentialOilSpec: _asStr(j["differentialOilSpec"]),
        brakeFluidSpec: _asStr(j["brakeFluidSpec"]),
        coolantSpec: _asStr(j["coolantSpec"]),
        fuelType: _asStr(j["fuelType"]),
        buildDate: _asStr(j["buildDate"]),
        firstRegistrationDate: _asStr(j["firstRegistrationDate"]),
        serviceIntervalDays: _asInt(j["serviceIntervalDays"]),
        serviceIntervalKm: _asInt(j["serviceIntervalKm"]),
        technicalCheckIntervalDays: _asInt(j["technicalCheckIntervalDays"]),
        currentKm: _asInt(j["currentKm"]),
        access: j["access"] == null ? "owner" : _asStr(j["access"]),
        provider: _asStr(j["provider"]),
        providerVehicleId: _asStr(j["providerVehicleId"]),
        hiddenTabs: _asStrList(j["hiddenTabs"]),
        hiddenFields: _asStrList(j["hiddenFields"]),
        tabOrder: _asStrList(j["tabOrder"]),
        fieldOrder: _asStrList(j["fieldOrder"]),
        metricOrder: _asStrList(j["metricOrder"]),
        hiddenServiceColumns: _asStrList(j["hiddenServiceColumns"]),
        serviceColumnOrder: _asStrList(j["serviceColumnOrder"]),
        hiddenServiceParts: _asStrList(j["hiddenServiceParts"]),
      );

  bool get isOwner => access == "owner";
  bool get canWrite => access == "owner" || access == "write";
  bool get isReadOnly => access == "read";

  String get subtitle =>
      [make, model, year > 0 ? "$year" : ""].where((s) => s.isNotEmpty).join(" ");
}

class ServiceRecord with HasAttachment {
  final String id;
  final String car;
  final DateTime? date;
  final int km;
  final bool changedOil;
  final bool changedEngineAirFilter;
  final bool changedCabinAirFilter;
  final String notes;
  final DateTime? nextServiceDate;
  final int? nextServiceKm;
  @override
  final String fileName;
  @override
  final bool hasFile;

  ServiceRecord({
    required this.id,
    required this.car,
    required this.date,
    required this.km,
    required this.changedOil,
    required this.changedEngineAirFilter,
    required this.changedCabinAirFilter,
    required this.notes,
    required this.nextServiceDate,
    required this.nextServiceKm,
    this.fileName = "",
    this.hasFile = false,
  });

  factory ServiceRecord.fromJson(Map<String, dynamic> j) => ServiceRecord(
        id: _asStr(j["id"]),
        car: _asStr(j["car"]),
        date: _asDate(j["date"]),
        km: _asInt(j["km"]),
        changedOil: _asBool(j["changedOil"]),
        changedEngineAirFilter: _asBool(j["changedEngineAirFilter"]),
        changedCabinAirFilter: _asBool(j["changedCabinAirFilter"]),
        notes: _asStr(j["notes"]),
        nextServiceDate: _asDate(j["nextServiceDate"]),
        nextServiceKm: _asIntOrNull(j["nextServiceKm"]),
        fileName: _asStr(j["fileName"]),
        hasFile: _asBool(j["hasFile"]),
      );
}

/// One mandatory roadworthiness inspection — przegląd techniczny, MOT, TÜV,
/// contrôle technique, depending on where the car is registered. Shaped like a
/// [ServiceRecord] but recurring on time alone: an inspection falls due on a
/// date whatever the odometer says.
class TechnicalCheck with HasAttachment {
  final String id;
  final String car;
  final DateTime? date;
  final String result; // passed | failed
  final double cost;
  final String station;
  final String notes;

  /// The expiry printed on the certificate. When set it wins over the car's
  /// interval, because it is the date that actually governs.
  final DateTime? validUntil;

  final DateTime? nextCheckDate;
  final ExpiryAssessment expiry;
  @override
  final String fileName;
  @override
  final bool hasFile;

  TechnicalCheck({
    required this.id,
    required this.car,
    required this.date,
    required this.result,
    required this.cost,
    this.station = "",
    this.notes = "",
    this.validUntil,
    this.nextCheckDate,
    this.expiry = const ExpiryAssessment(),
    this.fileName = "",
    this.hasFile = false,
  });

  factory TechnicalCheck.fromJson(Map<String, dynamic> j) => TechnicalCheck(
        id: _asStr(j["id"]),
        car: _asStr(j["car"]),
        date: _asDate(j["date"]),
        result: _asStr(j["result"]).isEmpty ? "passed" : _asStr(j["result"]),
        cost: _asDouble(j["cost"]),
        station: _asStr(j["station"]),
        notes: _asStr(j["notes"]),
        validUntil: _asDate(j["validUntil"]),
        nextCheckDate: _asDate(j["nextCheckDate"]),
        expiry: ExpiryAssessment.fromJson(
            j["expiry"] == null ? null : Map<String, dynamic>.from(j["expiry"])),
        fileName: _asStr(j["fileName"]),
        hasFile: _asBool(j["hasFile"]),
      );

  bool get passed => result == "passed";
}

class Part with HasAttachment {
  final String id;
  final String car;
  final String name;
  final String partNumber;
  final String category;
  final String notes;
  @override
  final String fileName;
  @override
  final bool hasFile;

  Part({
    required this.id,
    required this.car,
    required this.name,
    required this.partNumber,
    this.category = "",
    this.notes = "",
    this.fileName = "",
    this.hasFile = false,
  });

  factory Part.fromJson(Map<String, dynamic> j) => Part(
        id: _asStr(j["id"]),
        car: _asStr(j["car"]),
        name: _asStr(j["name"]),
        partNumber: _asStr(j["partNumber"]),
        category: _asStr(j["category"]),
        notes: _asStr(j["notes"]),
        fileName: _asStr(j["fileName"]),
        hasFile: _asBool(j["hasFile"]),
      );
}

/// One refuelling stop. The efficiency figures are derived by the server using
/// the full-tank method and are null wherever it could not compute them — a
/// window with a missed fill, or the first tank ever logged.
class FuelEntry with HasAttachment {
  final String id;
  final String car;
  final DateTime? date;
  final int km;
  final double liters;
  final double cost;
  final bool fullTank;
  final bool missedFill;
  final String station;
  final String notes;

  final double? pricePerLiter;
  final int? distanceKm;
  final double? litersUsed;
  final double? consumptionL100;
  final double? kmPerLiter;
  final double? costPerKm;
  @override
  final String fileName;
  @override
  final bool hasFile;

  FuelEntry({
    required this.id,
    required this.car,
    required this.date,
    required this.km,
    required this.liters,
    required this.cost,
    this.fullTank = true,
    this.missedFill = false,
    this.station = "",
    this.notes = "",
    this.pricePerLiter,
    this.distanceKm,
    this.litersUsed,
    this.consumptionL100,
    this.kmPerLiter,
    this.costPerKm,
    this.fileName = "",
    this.hasFile = false,
  });

  factory FuelEntry.fromJson(Map<String, dynamic> j) => FuelEntry(
        id: _asStr(j["id"]),
        car: _asStr(j["car"]),
        date: _asDate(j["date"]),
        km: _asInt(j["km"]),
        liters: _asDouble(j["liters"]),
        cost: _asDouble(j["cost"]),
        fullTank: _asBool(j["fullTank"]),
        missedFill: _asBool(j["missedFill"]),
        station: _asStr(j["station"]),
        notes: _asStr(j["notes"]),
        pricePerLiter: _asDoubleOrNull(j["pricePerLiter"]),
        distanceKm: _asIntOrNull(j["distanceKm"]),
        litersUsed: _asDoubleOrNull(j["litersUsed"]),
        consumptionL100: _asDoubleOrNull(j["consumptionL100"]),
        kmPerLiter: _asDoubleOrNull(j["kmPerLiter"]),
        costPerKm: _asDoubleOrNull(j["costPerKm"]),
        fileName: _asStr(j["fileName"]),
        hasFile: _asBool(j["hasFile"]),
      );
}

/// A summary of a car's whole refill history. [trackedDistanceKm] is the
/// distance covered by computable full-tank windows — less than the odometer
/// span whenever the history starts or ends on a partial fill. The averages
/// describe exactly this distance.
class FuelStats {
  final int entries;
  final double totalLiters;
  final double totalCost;
  final int trackedDistanceKm;
  final double? avgConsumptionL100;
  final double? bestConsumptionL100;
  final double? worstConsumptionL100;
  final double? avgKmPerLiter;
  final double? avgPricePerLiter;
  final double? costPerKm;
  final DateTime? firstDate;
  final DateTime? lastDate;

  const FuelStats({
    this.entries = 0,
    this.totalLiters = 0,
    this.totalCost = 0,
    this.trackedDistanceKm = 0,
    this.avgConsumptionL100,
    this.bestConsumptionL100,
    this.worstConsumptionL100,
    this.avgKmPerLiter,
    this.avgPricePerLiter,
    this.costPerKm,
    this.firstDate,
    this.lastDate,
  });

  factory FuelStats.fromJson(Map<String, dynamic> j) => FuelStats(
        entries: _asInt(j["entries"]),
        totalLiters: _asDouble(j["totalLiters"]),
        totalCost: _asDouble(j["totalCost"]),
        trackedDistanceKm: _asInt(j["trackedDistanceKm"]),
        avgConsumptionL100: _asDoubleOrNull(j["avgConsumptionL100"]),
        bestConsumptionL100: _asDoubleOrNull(j["bestConsumptionL100"]),
        worstConsumptionL100: _asDoubleOrNull(j["worstConsumptionL100"]),
        avgKmPerLiter: _asDoubleOrNull(j["avgKmPerLiter"]),
        avgPricePerLiter: _asDoubleOrNull(j["avgPricePerLiter"]),
        costPerKm: _asDoubleOrNull(j["costPerKm"]),
        firstDate: _asDate(j["firstDate"]),
        lastDate: _asDate(j["lastDate"]),
      );
}

/// One charge of an EV — the electric counterpart of [FuelEntry], and derived
/// the same way: consumption is measured between full charges, so a top-up
/// counts towards the next full one. The server computes every figure below the
/// raw kWh/cost and omits any it could not derive.
class ChargingSession with HasAttachment {
  final String id;
  final String car;
  final DateTime? date;
  final int km;
  final double kwh;
  final double cost;

  /// Taken to the car's usual full point — the reference the efficiency windows
  /// are measured between.
  final bool fullCharge;

  /// The car was charged before this without being logged, so the odometer span
  /// is not accounted for by the kWh on record. Any window containing it is
  /// left uncomputed rather than reported as implausibly efficient.
  final bool missedSession;

  final String location;
  final String notes;

  final double? pricePerKwh;
  final int? distanceKm;
  final double? kwhUsed;
  final double? consumptionKwh100;
  final double? kmPerKwh;
  final double? costPerKm;
  @override
  final String fileName;
  @override
  final bool hasFile;

  ChargingSession({
    required this.id,
    required this.car,
    required this.date,
    required this.km,
    required this.kwh,
    required this.cost,
    this.fullCharge = true,
    this.missedSession = false,
    this.location = "",
    this.notes = "",
    this.pricePerKwh,
    this.distanceKm,
    this.kwhUsed,
    this.consumptionKwh100,
    this.kmPerKwh,
    this.costPerKm,
    this.fileName = "",
    this.hasFile = false,
  });

  factory ChargingSession.fromJson(Map<String, dynamic> j) => ChargingSession(
        id: _asStr(j["id"]),
        car: _asStr(j["car"]),
        date: _asDate(j["date"]),
        km: _asInt(j["km"]),
        kwh: _asDouble(j["kwh"]),
        cost: _asDouble(j["cost"]),
        fullCharge: _asBool(j["fullCharge"]),
        missedSession: _asBool(j["missedSession"]),
        location: _asStr(j["location"]),
        notes: _asStr(j["notes"]),
        pricePerKwh: _asDoubleOrNull(j["pricePerKwh"]),
        distanceKm: _asIntOrNull(j["distanceKm"]),
        kwhUsed: _asDoubleOrNull(j["kwhUsed"]),
        consumptionKwh100: _asDoubleOrNull(j["consumptionKwh100"]),
        kmPerKwh: _asDoubleOrNull(j["kmPerKwh"]),
        costPerKm: _asDoubleOrNull(j["costPerKm"]),
        fileName: _asStr(j["fileName"]),
        hasFile: _asBool(j["hasFile"]),
      );
}

/// A summary of a car's whole charging history. [trackedDistanceKm] is the
/// distance covered by computable full-charge windows — the same caveat as
/// [FuelStats.trackedDistanceKm].
class ChargingStats {
  final int entries;
  final double totalKwh;
  final double totalCost;
  final int trackedDistanceKm;
  final double? avgConsumptionKwh100;
  final double? bestConsumptionKwh100;
  final double? worstConsumptionKwh100;
  final double? avgKmPerKwh;
  final double? avgPricePerKwh;
  final double? costPerKm;
  final DateTime? firstDate;
  final DateTime? lastDate;

  const ChargingStats({
    this.entries = 0,
    this.totalKwh = 0,
    this.totalCost = 0,
    this.trackedDistanceKm = 0,
    this.avgConsumptionKwh100,
    this.bestConsumptionKwh100,
    this.worstConsumptionKwh100,
    this.avgKmPerKwh,
    this.avgPricePerKwh,
    this.costPerKm,
    this.firstDate,
    this.lastDate,
  });

  factory ChargingStats.fromJson(Map<String, dynamic> j) => ChargingStats(
        entries: _asInt(j["entries"]),
        totalKwh: _asDouble(j["totalKwh"]),
        totalCost: _asDouble(j["totalCost"]),
        trackedDistanceKm: _asInt(j["trackedDistanceKm"]),
        avgConsumptionKwh100: _asDoubleOrNull(j["avgConsumptionKwh100"]),
        bestConsumptionKwh100: _asDoubleOrNull(j["bestConsumptionKwh100"]),
        worstConsumptionKwh100: _asDoubleOrNull(j["worstConsumptionKwh100"]),
        avgKmPerKwh: _asDoubleOrNull(j["avgKmPerKwh"]),
        avgPricePerKwh: _asDoubleOrNull(j["avgPricePerKwh"]),
        costPerKm: _asDoubleOrNull(j["costPerKm"]),
        firstDate: _asDate(j["firstDate"]),
        lastDate: _asDate(j["lastDate"]),
      );
}

/// One workshop visit or repair — work done outside the routine service
/// schedule (which lives in [ServiceRecord]). A broken alternator replaced at a
/// garage belongs here; the annual oil change does not.
class MaintenanceEntry with HasAttachment {
  final String id;
  final String car;
  final DateTime? date;
  final int km;
  final String type; // repair|inspection|bodywork|tyres|diagnostics|recall|warranty|other
  final String status; // scheduled|in_progress|completed
  final String workshop;
  final String location;
  final String description;
  final String partsUsed;
  final double laborCost;
  final double partsCost;
  final String invoiceNumber;
  final DateTime? warrantyUntil;
  final String notes;

  final double totalCost;
  final bool? warrantyActive;
  final int? warrantyDaysLeft;
  @override
  final String fileName;
  @override
  final bool hasFile;

  MaintenanceEntry({
    required this.id,
    required this.car,
    required this.date,
    required this.km,
    required this.type,
    required this.status,
    this.workshop = "",
    this.location = "",
    this.description = "",
    this.partsUsed = "",
    this.laborCost = 0,
    this.partsCost = 0,
    this.invoiceNumber = "",
    this.warrantyUntil,
    this.notes = "",
    this.totalCost = 0,
    this.warrantyActive,
    this.warrantyDaysLeft,
    this.fileName = "",
    this.hasFile = false,
  });

  factory MaintenanceEntry.fromJson(Map<String, dynamic> j) => MaintenanceEntry(
        id: _asStr(j["id"]),
        car: _asStr(j["car"]),
        date: _asDate(j["date"]),
        km: _asInt(j["km"]),
        type: _asStr(j["type"]).isEmpty ? "repair" : _asStr(j["type"]),
        status: _asStr(j["status"]).isEmpty ? "completed" : _asStr(j["status"]),
        workshop: _asStr(j["workshop"]),
        location: _asStr(j["location"]),
        description: _asStr(j["description"]),
        partsUsed: _asStr(j["partsUsed"]),
        laborCost: _asDouble(j["laborCost"]),
        partsCost: _asDouble(j["partsCost"]),
        invoiceNumber: _asStr(j["invoiceNumber"]),
        warrantyUntil: _asDate(j["warrantyUntil"]),
        notes: _asStr(j["notes"]),
        totalCost: _asDouble(j["totalCost"]),
        warrantyActive: j["warrantyActive"] == null ? null : _asBool(j["warrantyActive"]),
        warrantyDaysLeft: _asIntOrNull(j["warrantyDaysLeft"]),
        fileName: _asStr(j["fileName"]),
        hasFile: _asBool(j["hasFile"]),
      );
}

/// A piece of paperwork tied to a car — insurance, emissions certificate,
/// registration papers. The renewal date is the point of the record: an expired
/// policy is a car that cannot legally be driven, so [expiry] is computed live
/// by the server on every read.
class CarDocument with HasAttachment {
  final String id;
  final String car;
  final String type; // insurance|pollution|registration|inspection|roadTax|warranty|other
  final String title;
  final String provider;
  final String reference;
  final DateTime? issueDate;
  final DateTime? expiryDate; // blank = never expires
  final double cost;
  final String notes;
  final ExpiryAssessment expiry;
  @override
  final String fileName;
  @override
  final bool hasFile;

  CarDocument({
    required this.id,
    required this.car,
    required this.type,
    required this.title,
    this.provider = "",
    this.reference = "",
    this.issueDate,
    this.expiryDate,
    this.cost = 0,
    this.notes = "",
    this.expiry = const ExpiryAssessment(),
    this.fileName = "",
    this.hasFile = false,
  });

  factory CarDocument.fromJson(Map<String, dynamic> j) => CarDocument(
        id: _asStr(j["id"]),
        car: _asStr(j["car"]),
        type: _asStr(j["type"]).isEmpty ? "other" : _asStr(j["type"]),
        title: _asStr(j["title"]),
        provider: _asStr(j["provider"]),
        reference: _asStr(j["reference"]),
        issueDate: _asDate(j["issueDate"]),
        expiryDate: _asDate(j["expiryDate"]),
        cost: _asDouble(j["cost"]),
        notes: _asStr(j["notes"]),
        expiry: ExpiryAssessment.fromJson(
            j["expiry"] == null ? null : Map<String, dynamic>.from(j["expiry"])),
        fileName: _asStr(j["fileName"]),
        hasFile: _asBool(j["hasFile"]),
      );
}

/// Something the user wants to be told about: a booked workshop slot, an
/// insurance renewal, a tyre swap. Fires on a date, an odometer reading, or
/// both — whichever comes first. [auto] marks a reminder the server derived from
/// a document or service record, which is read-only.
class Reminder {
  final String id;
  final String car;
  final String title;
  final String type; // maintenance|document|service|inspection|other
  final DateTime? dueDate;
  final int dueKm;
  final int repeatDays;
  final int repeatKm;
  final bool done;
  final DateTime? doneAt;
  final String notes;

  final String status; // done | overdue | due_soon | upcoming | no_trigger
  final int? daysLeft;
  final int? kmLeft;
  final bool auto;
  final String sourceRef;

  Reminder({
    required this.id,
    required this.car,
    required this.title,
    required this.type,
    this.dueDate,
    this.dueKm = 0,
    this.repeatDays = 0,
    this.repeatKm = 0,
    this.done = false,
    this.doneAt,
    this.notes = "",
    this.status = "no_trigger",
    this.daysLeft,
    this.kmLeft,
    this.auto = false,
    this.sourceRef = "",
  });

  factory Reminder.fromJson(Map<String, dynamic> j) => Reminder(
        id: _asStr(j["id"]),
        car: _asStr(j["car"]),
        title: _asStr(j["title"]),
        type: _asStr(j["type"]).isEmpty ? "other" : _asStr(j["type"]),
        dueDate: _asDate(j["dueDate"]),
        dueKm: _asInt(j["dueKm"]),
        repeatDays: _asInt(j["repeatDays"]),
        repeatKm: _asInt(j["repeatKm"]),
        done: _asBool(j["done"]),
        doneAt: _asDate(j["doneAt"]),
        notes: _asStr(j["notes"]),
        status: _asStr(j["status"]).isEmpty ? "no_trigger" : _asStr(j["status"]),
        daysLeft: _asIntOrNull(j["daysLeft"]),
        kmLeft: _asIntOrNull(j["kmLeft"]),
        auto: _asBool(j["auto"]),
        sourceRef: _asStr(j["sourceRef"]),
      );

  /// Recurring reminders roll their trigger forward on completion instead of
  /// closing out.
  bool get repeats => repeatDays > 0 || repeatKm > 0;
}

/// A sharing grant: another user's access to one of your cars.
class CarShare {
  final String userId;
  final String email;
  final String name;
  final String permission; // "read" | "write"

  CarShare({
    required this.userId,
    required this.email,
    required this.name,
    required this.permission,
  });

  factory CarShare.fromJson(Map<String, dynamic> j) {
    final user = Map<String, dynamic>.from(j["user"] ?? {});
    return CarShare(
      userId: _asStr(user["id"]),
      email: _asStr(user["email"]),
      name: _asStr(user["name"]),
      permission: _asStr(j["permission"]),
    );
  }

  String get label => name.isNotEmpty ? name : email;
}

/// Roles allowed to manage users. A superadmin is an admin that also spans
/// every organization; the API Server enforces that difference.
const _managerRoles = {"admin", "superadmin"};

class AuthUser {
  final String id;
  final String email;
  final String name;
  final String role; // "user" | "admin" | "superadmin"
  AuthUser({required this.id, required this.email, required this.name, this.role = "user"});

  factory AuthUser.fromJson(Map<String, dynamic> j) => AuthUser(
        id: _asStr(j["id"]),
        email: _asStr(j["email"]),
        name: _asStr(j["name"]),
        // An empty role is treated as "user", matching the server.
        role: _asStr(j["role"]).isEmpty ? "user" : _asStr(j["role"]),
      );

  bool get isAdmin => _managerRoles.contains(role);
  bool get isSuperadmin => role == "superadmin";

  Map<String, dynamic> toJson() => {"id": id, "email": email, "name": name, "role": role};
}

/// A user record as returned by the admin user-management endpoints.
class AdminUser {
  final String id;
  final String email;
  final String name;
  final String role;
  final String created;
  final String organizationName; // "" when the user belongs to no organization

  AdminUser({
    required this.id,
    required this.email,
    required this.name,
    required this.role,
    required this.created,
    this.organizationName = "",
  });

  factory AdminUser.fromJson(Map<String, dynamic> j) => AdminUser(
        id: _asStr(j["id"]),
        email: _asStr(j["email"]),
        name: _asStr(j["name"]),
        role: _asStr(j["role"]).isEmpty ? "user" : _asStr(j["role"]),
        created: _asStr(j["created"]),
        organizationName: _asStr(j["organizationName"]),
      );

  bool get isSuperadmin => role == "superadmin";
}

/// A tenant users belong to, as returned by the organization endpoints.
class Organization {
  final String id;
  final String name;
  final String created;

  Organization({required this.id, required this.name, this.created = ""});

  factory Organization.fromJson(Map<String, dynamic> j) => Organization(
        id: _asStr(j["id"]),
        name: _asStr(j["name"]),
        created: _asStr(j["created"]),
      );
}

/// The full authenticated profile (Settings panel), mirroring /api/me.
class UserProfile {
  final String id;
  final String email;
  final bool verified;
  final String name;
  final String bio;
  final bool hasAvatar;
  final String theme; // light | dark | system
  final String locale; // BCP-47 language-REGION, e.g. "en-US"
  final String dateFormat; // YMD | DMY_NUM | DMY | MDY
  final String currency; // ISO 4217 code, e.g. "EUR"
  final String fontSize; // small | medium | large
  final String role; // user | admin
  final String organization; // org record id ("" = belongs to no organization)
  final String organizationName; // resolved name ("" when unset/unresolvable)

  /// The charging page's arrangements — which order its tabs and its cards are
  /// in. Kept on the profile rather than on the device because they are layout
  /// choices that should follow the account, the way the garage order does.
  final List<String> chargerTabOrder;
  final List<String> chargerCardOrder;

  final DateTime? deletionRequestedAt;

  UserProfile({
    required this.id,
    required this.email,
    required this.verified,
    required this.name,
    required this.bio,
    required this.hasAvatar,
    required this.theme,
    required this.locale,
    required this.dateFormat,
    this.currency = "USD",
    required this.fontSize,
    required this.role,
    this.organization = "",
    this.organizationName = "",
    this.chargerTabOrder = const [],
    this.chargerCardOrder = const [],
    required this.deletionRequestedAt,
  });

  factory UserProfile.fromJson(Map<String, dynamic> j) => UserProfile(
        id: _asStr(j["id"]),
        email: _asStr(j["email"]),
        verified: _asBool(j["verified"]),
        name: _asStr(j["name"]),
        bio: _asStr(j["bio"]),
        hasAvatar: _asBool(j["hasAvatar"]),
        theme: j["theme"] == null ? "system" : _asStr(j["theme"]),
        locale: j["locale"] == null ? "en-US" : _asStr(j["locale"]),
        dateFormat: j["dateFormat"] == null ? "YMD" : _asStr(j["dateFormat"]),
        currency: j["currency"] == null ? "USD" : _asStr(j["currency"]),
        fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
        role: j["role"] == null ? "user" : _asStr(j["role"]),
        organization: _asStr(j["organization"]),
        organizationName: _asStr(j["organizationName"]),
        chargerTabOrder: _asStrList(j["chargerTabOrder"]),
        chargerCardOrder: _asStrList(j["chargerCardOrder"]),
        deletionRequestedAt: j["deletionRequestedAt"] == null
            ? null
            : DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
      );

  bool get isAdmin => _managerRoles.contains(role);
  bool get isSuperadmin => role == "superadmin";
  bool get deletionPending => deletionRequestedAt != null;
}

// The Session model is gone: auth now uses PocketBase's own stateless tokens,
// so there is no per-device session list to show or revoke.

// --- Integrations (Toyota / Anker Solix) -----------------------------------
//
// The server resolves a superadmin → org admin → user cascade and returns, per
// field, the effective value (secrets/inherited values masked), the caller's
// own-layer value, its source layer, and whether it's locked (set above us).
// These mirror the resolved shapes the web Settings.vue consumes.

/// One field within a scope: the resolved value plus provenance.
class IntegrationField {
  final String effective; // resolved value (secrets masked to "")
  final String own; // the caller's own-layer value (unmasked, editable)
  final String source; // "global" | "org" | "user" | "unset"
  final bool locked; // set above the caller's editable layer

  const IntegrationField({
    this.effective = "",
    this.own = "",
    this.source = "unset",
    this.locked = false,
  });

  factory IntegrationField.fromJson(Map<String, dynamic> j) => IntegrationField(
        effective: _asStr(j["effective"]),
        own: _asStr(j["own"]),
        source: _asStr(j["source"]).isEmpty ? "unset" : _asStr(j["source"]),
        locked: _asBool(j["locked"]),
      );
}

/// A cascade scope ("user" or "org") the caller may edit, with its fields.
class IntegrationScope {
  final String editableLayer; // "user" | "org"
  final Map<String, IntegrationField> fields;

  const IntegrationScope({this.editableLayer = "user", this.fields = const {}});

  factory IntegrationScope.fromJson(Map<String, dynamic> j) {
    final raw = j["fields"];
    final fields = <String, IntegrationField>{};
    if (raw is Map) {
      raw.forEach((k, v) {
        if (v is Map) fields[k.toString()] = IntegrationField.fromJson(Map<String, dynamic>.from(v));
      });
    }
    return IntegrationScope(
      editableLayer: _asStr(j["editableLayer"]).isEmpty ? "user" : _asStr(j["editableLayer"]),
      fields: fields,
    );
  }

  IntegrationField field(String key) => fields[key] ?? const IntegrationField();
}

/// The resolved view of one integration for the signed-in user, from
/// GET /integrations/{toyota|anker-solix}.
class IntegrationView {
  final bool available; // the integration is enabled at the master layer
  final bool enabled; // the user's personal opt-in
  final bool orgEnabled; // the organization gate
  final String orgId; // "" when the user has no organization
  final bool canEditOrg; // the caller is an org admin
  final bool isSuperadmin; // manages the shared layer elsewhere (read-only here)
  final String controlMode; // Anker only: effective control mode (off|own|proxy|modbus)
  final Map<String, IntegrationScope> scopes; // "user" and optionally "org"

  const IntegrationView({
    this.available = false,
    this.enabled = false,
    this.orgEnabled = false,
    this.orgId = "",
    this.canEditOrg = false,
    this.isSuperadmin = false,
    this.controlMode = "off",
    this.scopes = const {},
  });

  factory IntegrationView.fromJson(Map<String, dynamic> j) {
    final raw = j["scopes"];
    final scopes = <String, IntegrationScope>{};
    if (raw is Map) {
      raw.forEach((k, v) {
        if (v is Map) scopes[k.toString()] = IntegrationScope.fromJson(Map<String, dynamic>.from(v));
      });
    }
    return IntegrationView(
      available: _asBool(j["available"]),
      enabled: _asBool(j["enabled"]),
      orgEnabled: _asBool(j["orgEnabled"]),
      orgId: _asStr(j["orgId"]),
      canEditOrg: _asBool(j["canEditOrg"]),
      isSuperadmin: _asBool(j["isSuperadmin"]),
      controlMode: _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]),
      scopes: scopes,
    );
  }

  IntegrationScope scope(String key) => scopes[key] ?? const IntegrationScope();
}

/// The result of a live connection probe (POST …/health).
class IntegrationHealth {
  final String status; // "ok" | "error" | …
  final String detail;
  const IntegrationHealth({this.status = "", this.detail = ""});

  factory IntegrationHealth.fromJson(Map<String, dynamic> j) => IntegrationHealth(
        status: _asStr(j["status"]),
        detail: _asStr(j["detail"]),
      );
}

/// A charger's control view, from …/chargers/{sn}/control — over whichever
/// transport the user's control mode selects. Serves the Settings provisioning
/// card (endpoint/token/connection) and the Charging home tab's live control.
///
/// The two transports are provisioned differently and report differently. OCPP
/// waits for the charger to dial us, so what it needs is a token installed into
/// the charger and what it reports is a session snapshot counting a meter in
/// watt-hours. Modbus dials the charger, so what it needs is an address on the
/// local network and what it reports is the whole register map — including a
/// session's own energy, which is not the same number as a meter reading.
class AnkerControl {
  final String endpoint; // OCPP backend URL to point the charger at
  final bool hasToken; // a per-charger token has been generated
  final String tokenHint; // last chars of the token, for display
  final bool connected; // the charger is reachable over the active transport
  final String controlMode; // off | mqtt | modbus | own | proxy
  final String connectorStatus; // OCPP connector status, e.g. "Charging"
  final int meterWh; // last OCPP meter reading in watt-hours

  /// Where the charger lives on the local network (Modbus mode only). Blank
  /// until an address is saved, which is what the Charging page asks for.
  final String modbusHost;
  final int modbusPort;

  /// The server's own sentence about why there is nothing to control — no
  /// address saved yet, or a charger that did not answer at the one there is.
  /// Better than a generic hint, because the server has already tried.
  final String detail;

  /// The charger's own snapshot, when the active transport produced one —
  /// Modbus registers or the cloud's device messages, which name the same
  /// quantities the same way.
  final ChargerStatus? device;

  const AnkerControl({
    this.endpoint = "",
    this.hasToken = false,
    this.tokenHint = "",
    this.connected = false,
    this.controlMode = "off",
    this.connectorStatus = "",
    this.meterWh = 0,
    this.modbusHost = "",
    this.modbusPort = 502,
    this.detail = "",
    this.device,
  });

  factory AnkerControl.fromJson(Map<String, dynamic> j) {
    final status = j["status"];
    final s = status is Map ? Map<String, dynamic>.from(status) : const <String, dynamic>{};
    final mode = _asStr(j["controlMode"]).isEmpty ? "off" : _asStr(j["controlMode"]);
    final port = _asInt(j["modbusPort"]);
    return AnkerControl(
      endpoint: _asStr(j["endpoint"]),
      hasToken: _asBool(j["hasToken"]),
      tokenHint: _asStr(j["tokenHint"]),
      connected: _asBool(j["connected"]),
      controlMode: mode,
      connectorStatus: _asStr(s["connectorStatus"]),
      meterWh: _asInt(s["meterWh"]),
      modbusHost: _asStr(j["modbusHost"]),
      modbusPort: port == 0 ? 502 : port,
      detail: _asStr(j["detail"]),
      device: (mode == "modbus" || mode == "mqtt") && status is Map
          ? ChargerStatus(Map<String, dynamic>.from(status))
          : null,
    );
  }

  bool get isModbus => controlMode == "modbus";

  /// The Anker cloud path: commands ride the connection the charger already
  /// holds to Anker, so nothing on the customer's side has to be reachable.
  bool get isCloud => controlMode == "mqtt";

  /// Both of those read the charger itself and answer with its own snapshot,
  /// where OCPP answers with the session our CSMS is holding.
  bool get readsDevice => isModbus || isCloud;

  /// The energy this card shows. The transports word a charging session
  /// differently — an OCPP snapshot counts a meter, the charger's own counts the
  /// session — and both land in the same tile.
  double get meterKwh => (readsDevice ? (device?.sessionWh ?? 0) : meterWh) / 1000.0;

  /// What the charger says it is doing, in whichever transport's words.
  String get statusLabel {
    final label = readsDevice ? (device?.statusDesc ?? "") : connectorStatus;
    return label.isEmpty ? "—" : label;
  }
}

/// The Anker charger's own snapshot, read over Modbus TCP or over Anker's cloud.
///
/// A wrapper over the raw JSON rather than forty declared fields: what a
/// transport reports is the server's to describe, every value is optional (a
/// charger on older firmware answers a shorter register block, and the cloud
/// sends its settings only after a command), and a reading added upstream
/// surfaces here without a change to this file. The getters name what the
/// Charging page reads, and each keeps null distinct from zero — a relay that
/// reported no temperature is not a relay at 0 °C.
class ChargerStatus {
  final Map<String, dynamic> raw;
  const ChargerStatus(this.raw);

  double? number(String key) => _asDoubleOrNull(raw[key]);
  int? integer(String key) => _asIntOrNull(raw[key]);
  bool? flag(String key) => raw[key] is bool ? raw[key] as bool : null;
  String text(String key) => _asStr(raw[key]);

  /// The control block, which the charger answers as its own object.
  Map<String, dynamic> get settings {
    final v = raw["settings"];
    return v is Map ? Map<String, dynamic>.from(v) : const {};
  }

  double? setting(String key) => _asDoubleOrNull(settings[key]);
  int? settingInt(String key) => _asIntOrNull(settings[key]);
  bool? settingFlag(String key) => settings[key] is bool ? settings[key] as bool : null;

  String get statusDesc => text("statusDesc");
  int get sessionWh => _asInt(raw["sessionWh"]);

  /// The modes the charger can be moved to from the one it is in. Only the
  /// cloud transport derives them — it is the only one that can see the boost
  /// flag and the countdowns the derivation depends on.
  List<String> get modeOptions {
    final v = raw["modeOptions"];
    return v is List ? v.map(_asStr).where((s) => s.isNotEmpty).toList() : const [];
  }

  /// What the charger says about its own local side, when the transport carries
  /// it: the address the Modbus mode otherwise has to be given by hand.
  Map<String, dynamic> get local {
    final v = raw["local"];
    return v is Map ? Map<String, dynamic>.from(v) : const {};
  }

  /// Everything the charger sent that no field above has a name for: the values
  /// a message carries that the integration has not modelled, and the fields no
  /// published map names at all, keyed by the message and field byte they
  /// arrived in. Raw and unscaled — a unit would be a meaning we do not have.
  Map<String, dynamic> get extra {
    final v = raw["extra"];
    return v is Map ? Map<String, dynamic>.from(v) : const {};
  }

  /// The alarm words, as they arrive: the spec defers what the individual bits
  /// mean to a list Anker does not publish, so which word is set is still the
  /// thing to report.
  bool get alarm => _asBool(raw["alarm"]);
  List<int> get alarms {
    final v = raw["alarms"];
    return v is List ? v.map(_asInt).toList() : const [];
  }
}

/// One EV charger on the linked Anker account, from …/anker-solix/chargers. The
/// server merges the cloud's several views of an account, so a charger reads the
/// same here whether it stands on its own or belongs to a system; a field no
/// view supplied simply stays empty.
class AnkerCharger {
  final String sn;
  final String name;
  final String model;
  final String firmware;
  final String siteName;
  final String statusDesc; // charging | standby | … as the cloud names it
  final bool? online; // null when no view reported a connection state

  const AnkerCharger({
    required this.sn,
    this.name = "",
    this.model = "",
    this.firmware = "",
    this.siteName = "",
    this.statusDesc = "",
    this.online,
  });

  factory AnkerCharger.fromJson(Map<String, dynamic> j) => AnkerCharger(
        sn: _asStr(j["sn"]),
        name: _asStr(j["name"]),
        model: _asStr(j["model"]),
        firmware: _asStr(j["firmware"]),
        siteName: _asStr(j["siteName"]),
        statusDesc: _asStr(j["statusDesc"]),
        online: j["online"] is bool ? j["online"] as bool : null,
      );

  /// What to call the charger in a list: its name when it has one, its serial
  /// otherwise — never an empty row.
  String get label => name.isNotEmpty ? name : sn;
}

/// The charger list plus the reason it may be empty (a gate that is off, or an
/// account that exposed nothing), so a caller can say why rather than show a
/// blank panel.
class AnkerChargerList {
  final List<AnkerCharger> chargers;
  final String detail;

  const AnkerChargerList({this.chargers = const [], this.detail = ""});

  factory AnkerChargerList.fromJson(Map<String, dynamic> j) {
    final raw = j["chargers"];
    return AnkerChargerList(
      chargers: raw is List
          ? raw
              .whereType<Map>()
              .map((c) => AnkerCharger.fromJson(Map<String, dynamic>.from(c)))
              .where((c) => c.sn.isNotEmpty)
              .toList()
          : const [],
      detail: _asStr(j["detail"]),
    );
  }
}

// --- home chargers (the user's own wallbox) ---------------------------------
//
// The garage's import, aimed at the wall: a charger on a connected service
// becomes a record of the user's own, and stays one after the account it came
// from is disconnected. It belongs to the person rather than to a car — it
// charges whichever car is plugged into it, and it outlives any of them.

/// One charger the user owns, from GET /home-chargers.
class HomeCharger {
  final String id;
  final String name;
  final String serial;
  final String vendor; // "Anker Solix", "Greencell"
  final String model; // "A5191"
  final String siteName; // the system it belongs to, where it has one
  final double powerKw;
  final String connector;

  /// The service it was imported from and that service's own id for it. Both
  /// blank for a charger added by hand; set only by the import, so a rename
  /// cannot break the link.
  final String provider;
  final String providerChargerId;

  final String created;

  const HomeCharger({
    required this.id,
    this.name = "",
    this.serial = "",
    this.vendor = "",
    this.model = "",
    this.siteName = "",
    this.powerKw = 0,
    this.connector = "",
    this.provider = "",
    this.providerChargerId = "",
    this.created = "",
  });

  factory HomeCharger.fromJson(Map<String, dynamic> j) => HomeCharger(
        id: _asStr(j["id"]),
        name: _asStr(j["name"]),
        serial: _asStr(j["serial"]),
        vendor: _asStr(j["vendor"]),
        model: _asStr(j["model"]),
        siteName: _asStr(j["siteName"]),
        powerKw: _asDouble(j["powerKw"]),
        connector: _asStr(j["connector"]),
        provider: _asStr(j["provider"]),
        providerChargerId: _asStr(j["providerChargerId"]),
        created: _asStr(j["created"]),
      );

  /// The line under the name in a list: what the charger is, in as many of the
  /// three terms as the record actually holds.
  String get subtitle =>
      [serial, model, siteName].where((s) => s.isNotEmpty).join(" · ");
}

/// One charger service the user could import from, from GET /charger-providers.
/// [detail] says, in one sentence, what to do about a closed gate — the same
/// shape the vehicle providers use.
class ChargerProvider {
  final String id;
  final String label; // "Anker Solix"
  final String service; // "Anker Solix cloud"
  final bool connected;
  final String detail;

  const ChargerProvider({
    required this.id,
    this.label = "",
    this.service = "",
    this.connected = false,
    this.detail = "",
  });

  factory ChargerProvider.fromJson(Map<String, dynamic> j) => ChargerProvider(
        id: _asStr(j["id"]),
        label: _asStr(j["label"]),
        service: _asStr(j["service"]),
        connected: _asBool(j["connected"]),
        detail: _asStr(j["detail"]),
      );
}

/// One charger as the service that has it describes it, from
/// /charger-providers/{provider}/chargers.
///
/// This is the live half: a record says what a charger *is*, and only the
/// service it came from knows whether it is reachable right now. The import
/// sheet lists these to pick from; the Charging page holds them beside the
/// imported records to say which are online.
class ProviderCharger {
  final String id; // the provider's own id — the serial, for both services
  final String name;
  final String vendor;
  final String model;
  final String firmware;
  final String siteId;
  final String siteName;
  final String status; // the service's own word for its state
  final bool? online; // null when the service reported no connection state

  /// How the charger is registered on the account — standalone, inside a
  /// system, or merely bound to it. A charger can be several at once.
  final List<String> sources;

  /// The charge power as the service words it. The unit is upstream's, so it is
  /// relayed verbatim rather than given one here.
  final String power;
  final int? ocppStatus;
  final String ocppStatusDesc;

  /// What the account knows about the box on the wall, as opposed to the
  /// charging: which networks it is on, where it thinks it is, when it was
  /// bound, and the picture the app shows for it.
  final String wifiName;
  final String wifiMac;
  final int? wifiRssi;
  final String bleMac;
  final String timeZone;
  final double? linkedAt; // unix seconds
  final String imageUrl;
  final List<String> relatedBy; // ble, wifi — how the app reaches it

  /// Everything else the service said about this charger, under its own field
  /// names. The fields above are the ones DriverVault has a name for; this is
  /// the remainder, kept rather than dropped — the service documents none of it,
  /// so its key is the only honest label these values have.
  final Map<String, String> attrs;

  /// Set when this charger is already in DriverVault, so the import never
  /// offers the same one twice.
  final String linkedChargerId;

  const ProviderCharger({
    required this.id,
    this.name = "",
    this.vendor = "",
    this.model = "",
    this.firmware = "",
    this.siteId = "",
    this.siteName = "",
    this.status = "",
    this.online,
    this.sources = const [],
    this.power = "",
    this.ocppStatus,
    this.ocppStatusDesc = "",
    this.wifiName = "",
    this.wifiMac = "",
    this.wifiRssi,
    this.bleMac = "",
    this.timeZone = "",
    this.linkedAt,
    this.imageUrl = "",
    this.relatedBy = const [],
    this.attrs = const {},
    this.linkedChargerId = "",
  });

  factory ProviderCharger.fromJson(Map<String, dynamic> j) => ProviderCharger(
        id: _asStr(j["id"]),
        name: _asStr(j["name"]),
        vendor: _asStr(j["vendor"]),
        model: _asStr(j["model"]),
        firmware: _asStr(j["firmware"]),
        siteId: _asStr(j["siteId"]),
        siteName: _asStr(j["siteName"]),
        status: _asStr(j["status"]),
        online: j["online"] is bool ? j["online"] as bool : null,
        sources: _asStrList(j["sources"]),
        power: _asStr(j["power"]),
        ocppStatus: _asIntOrNull(j["ocppStatus"]),
        ocppStatusDesc: _asStr(j["ocppStatusDesc"]),
        wifiName: _asStr(j["wifiName"]),
        wifiMac: _asStr(j["wifiMac"]),
        wifiRssi: _asIntOrNull(j["wifiRssi"]),
        bleMac: _asStr(j["bleMac"]),
        timeZone: _asStr(j["timeZone"]),
        linkedAt: _asDoubleOrNull(j["linkedAt"]),
        imageUrl: _asStr(j["imageUrl"]),
        relatedBy: _asStrList(j["relatedBy"]),
        attrs: _asStrMap(j["attrs"]),
        linkedChargerId: _asStr(j["linkedChargerId"]),
      );

  String get subtitle =>
      [vendor, model, siteName].where((s) => s.isNotEmpty).join(" · ");

  /// The OCPP connector state as the *service* sees it — the cloud's own
  /// reading, not our CSMS's. It words it when it can and numbers it when it
  /// cannot.
  String get ocppLabel {
    if (ocppStatusDesc.isNotEmpty) return ocppStatusDesc;
    return ocppStatus == null ? "" : "$ocppStatus";
  }
}

/// One of the account's views of a charger — the station record, the totals,
/// the history, the OCPP backend, the cards, the sharing, the firmware, or any
/// of the others. Relayed as the fields it sent: Anker documents none of these
/// payloads, so the cloud's own keys are the only honest labels.
class ChargerDetailView {
  final String id;
  final Map<String, String> attrs;

  /// The one line in DriverVault's words rather than the cloud's: a number this
  /// view reports that another view gives a meaning to, resolved by the server.
  final String note;
  final String error;

  const ChargerDetailView({required this.id, this.attrs = const {}, this.note = "", this.error = ""});

  factory ChargerDetailView.fromJson(Map<String, dynamic> j) => ChargerDetailView(
        id: _asStr(j["id"]),
        attrs: _asStrMap(j["attrs"]),
        note: _asStr(j["note"]),
        error: _asStr(j["error"]),
      );

  /// The fields, sorted, so the same charger reads the same way every time.
  List<(String, String)> get rows {
    final keys = attrs.keys.toList()..sort();
    return [for (final key in keys) (key, attrs[key]!)];
  }
}

/// Every per-charger view for one charger, from
/// …/anker-solix/chargers/{sn}/details.
class ChargerDetails {
  final String sn;
  final List<ChargerDetailView> views;

  const ChargerDetails({this.sn = "", this.views = const []});

  factory ChargerDetails.fromJson(Map<String, dynamic> j) {
    final raw = j["views"];
    return ChargerDetails(
      sn: _asStr(j["sn"]),
      views: raw is List
          ? raw
              .whereType<Map>()
              .map((v) => ChargerDetailView.fromJson(Map<String, dynamic>.from(v)))
              .toList()
          : const [],
    );
  }
}

/// The chargers on one provider account, plus the reason the list may be empty:
/// a gate that is off answers 200 with nothing and a sentence, so the UI can say
/// "connect this in Settings" rather than show a blank panel.
class ProviderChargerList {
  final List<ProviderCharger> chargers;
  final bool unavailable;
  final String detail;

  const ProviderChargerList({
    this.chargers = const [],
    this.unavailable = false,
    this.detail = "",
  });

  factory ProviderChargerList.fromJson(Map<String, dynamic> j) {
    final raw = j["chargers"];
    return ProviderChargerList(
      chargers: raw is List
          ? raw
              .whereType<Map>()
              .map((c) => ProviderCharger.fromJson(Map<String, dynamic>.from(c)))
              .where((c) => c.id.isNotEmpty)
              .toList()
          : const [],
      unavailable: _asBool(j["unavailable"]),
      detail: _asStr(j["detail"]),
    );
  }
}

// --- vehicle providers (the connected-service tab) ---------------------------
//
// A provider is a manufacturer service a car can be linked to (MyToyota today).
// Nothing below models an upstream schema: the API Server flattens whatever the
// plugin returned into key/value pairs, so a provider adding a field surfaces it
// here without a change to this file. See the API's vehicleproviders.go.

/// One registered provider and whether this user can currently use it.
/// [detail] says, in one sentence, what to do about a closed gate.
class VehicleProvider {
  final String id;
  final String label; // "MyToyota"
  final String service; // "Toyota Connected Europe"
  final bool connected;
  final String detail;

  const VehicleProvider({
    required this.id,
    required this.label,
    this.service = "",
    this.connected = false,
    this.detail = "",
  });

  factory VehicleProvider.fromJson(Map<String, dynamic> j) => VehicleProvider(
        id: _asStr(j["id"]),
        label: _asStr(j["label"]),
        service: _asStr(j["service"]),
        connected: _asBool(j["connected"]),
        detail: _asStr(j["detail"]),
      );
}

/// One leaf of a provider payload, flattened to a dotted path.
class ProviderField {
  final String key;
  final String value;
  const ProviderField({required this.key, required this.value});

  factory ProviderField.fromJson(Map<String, dynamic> j) =>
      ProviderField(key: _asStr(j["key"]), value: _asStr(j["value"]));

  static List<ProviderField> listFrom(dynamic v) => v is List
      ? v.map((e) => ProviderField.fromJson(Map<String, dynamic>.from(e))).toList()
      : const [];
}

/// One vehicle on the user's provider account.
class ProviderVehicle {
  final String id; // the provider's own id (the VIN, for Toyota)
  final String vin;
  final String name;
  final String make;
  final String model;
  final int year;
  final String imageUrl;
  final List<ProviderField> fields;

  /// Set when this user already has a car linked to this vehicle, so the UI can
  /// say so instead of offering to link it twice.
  final String linkedCarId;

  const ProviderVehicle({
    required this.id,
    this.vin = "",
    this.name = "",
    this.make = "",
    this.model = "",
    this.year = 0,
    this.imageUrl = "",
    this.fields = const [],
    this.linkedCarId = "",
  });

  factory ProviderVehicle.fromJson(Map<String, dynamic> j) => ProviderVehicle(
        id: _asStr(j["id"]),
        vin: _asStr(j["vin"]),
        name: _asStr(j["name"]),
        make: _asStr(j["make"]),
        model: _asStr(j["model"]),
        year: _asInt(j["year"]),
        imageUrl: _asStr(j["imageUrl"]),
        fields: ProviderField.listFrom(j["fields"]),
        linkedCarId: _asStr(j["linkedCarId"]),
      );

  String get subtitle =>
      [make, model, year > 0 ? "$year" : ""].where((s) => s.isNotEmpty).join(" ");
}

/// A headline reading lifted out of the sections — the few values worth showing
/// large. [key] is a stable id the app localizes (car.provider.metrics.*).
class ProviderMetric {
  final String key;
  final String value;
  final String unit;
  const ProviderMetric({required this.key, required this.value, this.unit = ""});

  factory ProviderMetric.fromJson(Map<String, dynamic> j) => ProviderMetric(
        key: _asStr(j["key"]),
        value: _asStr(j["value"]),
        unit: _asStr(j["unit"]),
      );
}

/// One capability's outcome. A section that fails carries its error and the rest
/// still render: half a snapshot beats an error page.
class ProviderSection {
  final String id;
  final String status; // ok | error | empty
  final String error;
  final List<ProviderField> fields;
  final bool truncated;

  const ProviderSection({
    required this.id,
    this.status = "ok",
    this.error = "",
    this.fields = const [],
    this.truncated = false,
  });

  factory ProviderSection.fromJson(Map<String, dynamic> j) => ProviderSection(
        id: _asStr(j["id"]),
        status: _asStr(j["status"]).isEmpty ? "ok" : _asStr(j["status"]),
        error: _asStr(j["error"]),
        fields: ProviderField.listFrom(j["fields"]),
        truncated: _asBool(j["truncated"]),
      );
}

/// Everything a provider can currently tell us about one car.
///
/// [unavailable] replaces the payload when the provider cannot be reached for
/// this car at all — not connected, or the vehicle is not on *this* user's
/// account. It arrives as a 200, because "we asked and here is why there is
/// nothing" is an answer rather than a failure.
class ProviderSnapshot {
  final String provider;
  final String label;
  final String service;
  final bool unavailable;
  final String detail;
  final DateTime? fetchedAt;
  final ProviderVehicle? vehicle;
  final List<ProviderMetric> metrics;
  final List<ProviderSection> sections;

  /// The odometer the provider reports when it is ahead of the car's stored
  /// reading — what the tab's "update odometer" offers. 0 when they agree.
  final int suggestedCurrentKm;

  const ProviderSnapshot({
    this.provider = "",
    this.label = "",
    this.service = "",
    this.unavailable = false,
    this.detail = "",
    this.fetchedAt,
    this.vehicle,
    this.metrics = const [],
    this.sections = const [],
    this.suggestedCurrentKm = 0,
  });

  factory ProviderSnapshot.fromJson(Map<String, dynamic> j) {
    final v = j["vehicle"];
    return ProviderSnapshot(
      provider: _asStr(j["provider"]),
      label: _asStr(j["label"]),
      service: _asStr(j["service"]),
      unavailable: _asBool(j["unavailable"]),
      detail: _asStr(j["detail"]),
      fetchedAt: _asDate(j["fetchedAt"]),
      vehicle: v is Map ? ProviderVehicle.fromJson(Map<String, dynamic>.from(v)) : null,
      metrics: j["metrics"] is List
          ? (j["metrics"] as List)
              .map((e) => ProviderMetric.fromJson(Map<String, dynamic>.from(e)))
              .toList()
          : const [],
      sections: j["sections"] is List
          ? (j["sections"] as List)
              .map((e) => ProviderSection.fromJson(Map<String, dynamic>.from(e)))
              .toList()
          : const [],
      suggestedCurrentKm: _asInt(j["suggestedCurrentKm"]),
    );
  }
}

/// What POST /me/import created. Every count is of brand-new records — the
/// import never merges with or overwrites anything already there.
class ImportResult {
  final int cars;
  final int services;
  final int parts;
  const ImportResult({this.cars = 0, this.services = 0, this.parts = 0});

  factory ImportResult.fromJson(Map<String, dynamic> j) => ImportResult(
        cars: _asInt(j["carsImported"]),
        services: _asInt(j["servicesImported"]),
        parts: _asInt(j["partsImported"]),
      );
}
