USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
1 contributor
2452 lines | 82.989kb
//
//  ChargeInsightsModel.swift
//  USB Meter
//
//  Created by Codex on 10/04/2026.
//

import Foundation

enum ChargedDeviceKind: String, Identifiable, Codable {
    case device
    case charger

    var id: String { rawValue }

    var title: String {
        switch self {
        case .device:
            return "Device"
        case .charger:
            return "Charger"
        }
    }

    var pluralTitle: String {
        switch self {
        case .device:
            return "Devices"
        case .charger:
            return "Chargers"
        }
    }

    var symbolName: String {
        switch self {
        case .device:
            return "iphone"
        case .charger:
            return "bolt.horizontal.circle"
        }
    }
}

enum ChargedDeviceClass: String, CaseIterable, Identifiable, Codable {
    case iphone
    case watch
    case powerbank
    case charger
    case other

    var id: String { rawValue }

    static var deviceCases: [ChargedDeviceClass] {
        allCases.filter { $0 != .charger }
    }

    var kind: ChargedDeviceKind {
        self == .charger ? .charger : .device
    }

    var title: String {
        switch self {
        case .iphone:
            return "iPhone"
        case .watch:
            return "Watch"
        case .powerbank:
            return "Powerbank"
        case .charger:
            return "Charger"
        case .other:
            return "Other"
        }
    }

    var symbolName: String {
        switch self {
        case .iphone:
            return "iphone"
        case .watch:
            return "applewatch"
        case .powerbank:
            return "battery.100.bolt"
        case .charger:
            return "bolt.badge.clock"
        case .other:
            return "shippingbox"
        }
    }

    var enforcedChargingSupport: (wired: Bool, wireless: Bool)? {
        switch self {
        case .watch:
            return (wired: false, wireless: true)
        case .powerbank:
            return (wired: true, wireless: false)
        case .charger:
            return (wired: false, wireless: true)
        case .iphone, .other:
            return nil
        }
    }

    var enforcedChargingStateAvailability: ChargingStateAvailability? {
        switch self {
        case .watch:
            return .onOnly
        case .powerbank:
            return .offOnly
        case .charger:
            return .onOnly
        case .iphone, .other:
            return nil
        }
    }

    var defaultChargingSupport: (wired: Bool, wireless: Bool) {
        if let enforcedChargingSupport {
            return enforcedChargingSupport
        }

        switch self {
        case .iphone:
            return (wired: true, wireless: true)
        case .watch:
            return (wired: false, wireless: true)
        case .powerbank:
            return (wired: true, wireless: false)
        case .charger:
            return (wired: false, wireless: true)
        case .other:
            return (wired: true, wireless: false)
        }
    }

    var defaultChargingStateAvailability: ChargingStateAvailability {
        enforcedChargingStateAvailability ?? {
            switch self {
            case .iphone:
                return .onOrOff
            case .watch:
                return .onOnly
            case .powerbank:
                return .offOnly
            case .charger, .other:
                return .onOrOff
            }
        }()
    }

    func normalizedChargingSupport(
        supportsWiredCharging: Bool,
        supportsWirelessCharging: Bool
    ) -> (wired: Bool, wireless: Bool) {
        enforcedChargingSupport ?? (wired: supportsWiredCharging, wireless: supportsWirelessCharging)
    }

    func normalizedChargingStateAvailability(
        _ chargingStateAvailability: ChargingStateAvailability
    ) -> ChargingStateAvailability {
        enforcedChargingStateAvailability ?? chargingStateAvailability
    }
}

enum ChargeSessionStatus: String {
    case active
    case paused
    case completed
    case abandoned

    var title: String {
        switch self {
        case .active:
            return "Active"
        case .paused:
            return "Paused"
        case .completed:
            return "Completed"
        case .abandoned:
            return "Abandoned"
        }
    }

    var isOpen: Bool {
        switch self {
        case .active, .paused:
            return true
        case .completed, .abandoned:
            return false
        }
    }
}

enum ChargeSessionSourceMode: String {
    case live
    case offline
    case blended

    var title: String {
        switch self {
        case .live:
            return "Live"
        case .offline:
            return "Offline Counters"
        case .blended:
            return "Blended"
        }
    }
}

enum ChargingTransportMode: String, CaseIterable, Identifiable, Codable {
    case wired
    case wireless

    var id: String { rawValue }

    var title: String {
        switch self {
        case .wired:
            return "Wired"
        case .wireless:
            return "Wireless"
        }
    }

    var symbolName: String {
        switch self {
        case .wired:
            return "cable.connector"
        case .wireless:
            return "dot.radiowaves.left.and.right"
        }
    }
}

enum ChargingStateMode: String, CaseIterable, Identifiable, Codable {
    case on
    case off

    var id: String { rawValue }

    var title: String {
        switch self {
        case .on:
            return "On"
        case .off:
            return "Off"
        }
    }

    var description: String {
        switch self {
        case .on:
            return "Device stays powered on while charging."
        case .off:
            return "Device is powered off while charging."
        }
    }
}

enum ChargingStateAvailability: String, CaseIterable, Identifiable, Codable {
    case onOnly
    case onOrOff
    case offOnly

    var id: String { rawValue }

    var title: String {
        switch self {
        case .onOnly:
            return "On Only"
        case .onOrOff:
            return "On or Off"
        case .offOnly:
            return "Off Only"
        }
    }

    var description: String {
        switch self {
        case .onOnly:
            return "The device can be recorded only while it is powered on."
        case .onOrOff:
            return "The session must specify whether the device is on or off."
        case .offOnly:
            return "The device can be recorded only while it is powered off."
        }
    }

    var supportedModes: [ChargingStateMode] {
        switch self {
        case .onOnly:
            return [.on]
        case .onOrOff:
            return [.on, .off]
        case .offOnly:
            return [.off]
        }
    }

    var supportsMultipleModes: Bool {
        supportedModes.count > 1
    }

    var supportsChargingWhileOff: Bool {
        self != .onOnly
    }

    static func fallback(for supportsChargingWhileOff: Bool) -> ChargingStateAvailability {
        supportsChargingWhileOff ? .onOrOff : .onOnly
    }
}

enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable {
    case wiredOn
    case wiredOff
    case wirelessOn
    case wirelessOff

    var id: String { rawValue }

    init(chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode) {
        switch (chargingTransportMode, chargingStateMode) {
        case (.wired, .on):
            self = .wiredOn
        case (.wired, .off):
            self = .wiredOff
        case (.wireless, .on):
            self = .wirelessOn
        case (.wireless, .off):
            self = .wirelessOff
        }
    }

    var chargingTransportMode: ChargingTransportMode {
        switch self {
        case .wiredOn, .wiredOff:
            return .wired
        case .wirelessOn, .wirelessOff:
            return .wireless
        }
    }

    var chargingStateMode: ChargingStateMode {
        switch self {
        case .wiredOn, .wirelessOn:
            return .on
        case .wiredOff, .wirelessOff:
            return .off
        }
    }

    var title: String {
        "\(chargingTransportMode.title) • \(chargingStateMode.title)"
    }

    var shortTitle: String {
        "\(chargingTransportMode.title) \(chargingStateMode.title)"
    }
}

enum WirelessChargingProfile: String, CaseIterable, Identifiable, Codable {
    case magsafe
    case genericQi

    var id: String { rawValue }

    var title: String {
        switch self {
        case .magsafe:
            return "MagSafe"
        case .genericQi:
            return "Generic Qi"
        }
    }

    var description: String {
        switch self {
        case .magsafe:
            return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity."
        case .genericQi:
            return "Use only automatic efficiency estimates and show a low-efficiency warning when needed."
        }
    }
}

enum ChargerType: String, CaseIterable, Identifiable, Codable {
    case appleMagSafe
    case appleWatch
    case genericMagSafe
    case genericQi

    var id: String { rawValue }

    var title: String {
        switch self {
        case .appleMagSafe: return "Apple MagSafe Charger"
        case .appleWatch: return "Apple Watch Charger"
        case .genericMagSafe: return "Generic MagSafe"
        case .genericQi: return "Generic Qi"
        }
    }

    var symbolName: String {
        switch self {
        case .appleMagSafe: return "magsafe.batterypack"
        case .appleWatch: return "applewatch.radiowaves.left.and.right"
        case .genericMagSafe: return "bolt.circle"
        case .genericQi: return "bolt.horizontal.circle"
        }
    }

    /// Whether this charger type uses magnetic alignment, enabling more accurate efficiency calibration.
    var supportsAlignment: Bool {
        switch self {
        case .appleMagSafe, .appleWatch, .genericMagSafe: return true
        case .genericQi: return false
        }
    }

    var wirelessChargingProfile: WirelessChargingProfile {
        supportsAlignment ? .magsafe : .genericQi
    }
}

enum ChargedDeviceTemplateIconSource: String, Codable {
    case systemSymbol
    case asset
}

struct ChargedDeviceTemplateIcon: Hashable, Codable {
    let type: ChargedDeviceTemplateIconSource
    let name: String
    let fallbackSystemName: String?

    static func systemSymbol(
        _ name: String,
        fallbackSystemName: String? = nil
    ) -> ChargedDeviceTemplateIcon {
        ChargedDeviceTemplateIcon(
            type: .systemSymbol,
            name: name,
            fallbackSystemName: fallbackSystemName
        )
    }

    func resolvedSystemSymbolName(fallbackSystemName: String) -> String {
        switch type {
        case .systemSymbol:
            return name
        case .asset:
            return self.fallbackSystemName ?? fallbackSystemName
        }
    }
}

struct ChargedDeviceTemplateDefinition: Identifiable, Hashable, Codable {
    let id: String
    let name: String
    let group: String
    let kind: ChargedDeviceKind
    let deviceClass: ChargedDeviceClass
    let icon: ChargedDeviceTemplateIcon
    let chargingStateAvailability: ChargingStateAvailability
    let supportsWiredCharging: Bool
    let supportsWirelessCharging: Bool
    let wirelessChargingProfile: WirelessChargingProfile
    let sortOrder: Int

    var chargingSupportSummary: String {
        switch (supportsWiredCharging, supportsWirelessCharging) {
        case (true, true):
            return "Wired + Wireless"
        case (true, false):
            return "Wired only"
        case (false, true):
            return "Wireless only"
        case (false, false):
            return "No charging transport"
        }
    }

    var capabilitySummary: String {
        if kind == .charger {
            return wirelessChargingProfile.title
        }
        var components = [chargingStateAvailability.title, chargingSupportSummary]
        if supportsWirelessCharging {
            components.append(wirelessChargingProfile.title)
        }
        return components.joined(separator: " • ")
    }
}

private struct ChargedDeviceTemplateDocument: Codable {
    let templates: [ChargedDeviceTemplateDefinition]
}

struct ChargedDeviceTemplateCatalog {
    static let shared = ChargedDeviceTemplateCatalog()

    let templates: [ChargedDeviceTemplateDefinition]
    private let templatesByID: [String: ChargedDeviceTemplateDefinition]

    private init(bundle: Bundle = .main) {
        let loadedTemplates: [ChargedDeviceTemplateDefinition]

        if let resourceURL = bundle.url(forResource: "ChargedDeviceTemplates", withExtension: "json"),
           let data = try? Data(contentsOf: resourceURL),
           let document = try? JSONDecoder().decode(ChargedDeviceTemplateDocument.self, from: data) {
            loadedTemplates = document.templates
        } else {
            loadedTemplates = []
        }

        self.templates = loadedTemplates.sorted { lhs, rhs in
            if lhs.group != rhs.group {
                return lhs.group < rhs.group
            }
            if lhs.sortOrder != rhs.sortOrder {
                return lhs.sortOrder < rhs.sortOrder
            }
            return lhs.name < rhs.name
        }
        self.templatesByID = Dictionary(uniqueKeysWithValues: self.templates.map { ($0.id, $0) })
    }

    func template(id: String?) -> ChargedDeviceTemplateDefinition? {
        guard let id else {
            return nil
        }
        return templatesByID[id]
    }

    func templates(for kind: ChargedDeviceKind) -> [ChargedDeviceTemplateDefinition] {
        templates.filter { $0.kind == kind }
    }
}

enum ProfileCategory: String, CaseIterable, Identifiable, Codable {
    case phone
    case tablet
    case laptop
    case watch
    case audioAccessory
    case accessoryCase
    case charger
    case powerbank
    case other

    var id: String { rawValue }

    var title: String {
        switch self {
        case .phone: return "Phone"
        case .tablet: return "Tablet"
        case .laptop: return "Laptop"
        case .watch: return "Watch"
        case .audioAccessory: return "Audio Accessory"
        case .accessoryCase: return "Charging Case"
        case .charger: return "Charger"
        case .powerbank: return "Powerbank"
        case .other: return "Other"
        }
    }

    var pluralTitle: String {
        switch self {
        case .phone: return "Phones"
        case .tablet: return "Tablets"
        case .laptop: return "Laptops"
        case .watch: return "Watches"
        case .audioAccessory: return "Audio Accessories"
        case .accessoryCase: return "Charging Cases"
        case .charger: return "Chargers"
        case .powerbank: return "Powerbanks"
        case .other: return "Other"
        }
    }

    var symbolName: String {
        switch self {
        case .phone: return "iphone"
        case .tablet: return "ipad"
        case .laptop: return "laptopcomputer"
        case .watch: return "applewatch"
        case .audioAccessory: return "earbuds.case"
        case .accessoryCase: return "airpods.case.fill"
        case .charger: return "bolt.horizontal.circle"
        case .powerbank: return "battery.100.bolt"
        case .other: return "shippingbox"
        }
    }

    var kind: ChargedDeviceKind {
        self == .charger ? .charger : .device
    }

    static func fromLegacyDeviceClass(_ deviceClass: ChargedDeviceClass) -> ProfileCategory {
        switch deviceClass {
        case .iphone: return .phone
        case .watch: return .watch
        case .powerbank: return .powerbank
        case .charger: return .charger
        case .other: return .other
        }
    }
}

struct DeviceProfileDefinition: Identifiable, Hashable, Codable {
    let id: String
    let name: String
    let group: String
    let category: ProfileCategory
    let icon: ChargedDeviceTemplateIcon
    let sortOrder: Int

    let capWiredCharging: Bool
    let capWirelessCharging: Bool
    let capWirelessProfiles: [WirelessChargingProfile]
    let capChargingStateAvailability: ChargingStateAvailability
    let capHasInternalSubject: Bool

    let defaultWirelessChargingProfile: WirelessChargingProfile?
    let defaultWiredMinimumCurrentAmps: Double?
    let defaultWirelessMinimumCurrentAmps: Double?
    let defaultWiredEstimatedBatteryCapacityWh: Double?
    let defaultWirelessEstimatedBatteryCapacityWh: Double?

    init(
        id: String,
        name: String,
        group: String,
        category: ProfileCategory,
        icon: ChargedDeviceTemplateIcon,
        sortOrder: Int,
        capWiredCharging: Bool,
        capWirelessCharging: Bool,
        capWirelessProfiles: [WirelessChargingProfile],
        capChargingStateAvailability: ChargingStateAvailability,
        capHasInternalSubject: Bool,
        defaultWirelessChargingProfile: WirelessChargingProfile? = nil,
        defaultWiredMinimumCurrentAmps: Double? = nil,
        defaultWirelessMinimumCurrentAmps: Double? = nil,
        defaultWiredEstimatedBatteryCapacityWh: Double? = nil,
        defaultWirelessEstimatedBatteryCapacityWh: Double? = nil
    ) {
        self.id = id
        self.name = name
        self.group = group
        self.category = category
        self.icon = icon
        self.sortOrder = sortOrder
        self.capWiredCharging = capWiredCharging
        self.capWirelessCharging = capWirelessCharging
        self.capWirelessProfiles = capWirelessProfiles
        self.capChargingStateAvailability = capChargingStateAvailability
        self.capHasInternalSubject = capHasInternalSubject
        self.defaultWirelessChargingProfile = defaultWirelessChargingProfile
        self.defaultWiredMinimumCurrentAmps = defaultWiredMinimumCurrentAmps
        self.defaultWirelessMinimumCurrentAmps = defaultWirelessMinimumCurrentAmps
        self.defaultWiredEstimatedBatteryCapacityWh = defaultWiredEstimatedBatteryCapacityWh
        self.defaultWirelessEstimatedBatteryCapacityWh = defaultWirelessEstimatedBatteryCapacityWh
    }

    var capabilitySummary: String {
        var components: [String] = [capChargingStateAvailability.title]
        switch (capWiredCharging, capWirelessCharging) {
        case (true, true): components.append("Wired + Wireless")
        case (true, false): components.append("Wired only")
        case (false, true): components.append("Wireless only")
        case (false, false): components.append("No transport")
        }
        if capWirelessCharging, let primary = defaultWirelessChargingProfile {
            components.append(primary.title)
        }
        return components.joined(separator: " • ")
    }

    var wirelessProfilesCSV: String {
        capWirelessProfiles.map { $0.rawValue }.joined(separator: ",")
    }

    static func decodeWirelessProfilesCSV(_ csv: String?) -> [WirelessChargingProfile] {
        guard let csv, !csv.isEmpty else { return [] }
        return csv
            .split(separator: ",")
            .compactMap { WirelessChargingProfile(rawValue: String($0).trimmingCharacters(in: .whitespaces)) }
    }
}

private struct DeviceProfileCatalogDocument: Codable {
    let profiles: [DeviceProfileDefinition]
}

struct DeviceProfileCatalog {
    static let shared = DeviceProfileCatalog()

    let profiles: [DeviceProfileDefinition]
    private let profilesByID: [String: DeviceProfileDefinition]

    private init(bundle: Bundle = .main) {
        let loaded: [DeviceProfileDefinition]

        if let resourceURL = bundle.url(forResource: "DeviceProfilesCatalog", withExtension: "json"),
           let data = try? Data(contentsOf: resourceURL),
           let document = try? JSONDecoder().decode(DeviceProfileCatalogDocument.self, from: data) {
            loaded = document.profiles
        } else {
            loaded = []
        }

        self.profiles = loaded.sorted { lhs, rhs in
            if lhs.group != rhs.group {
                return lhs.group < rhs.group
            }
            if lhs.sortOrder != rhs.sortOrder {
                return lhs.sortOrder < rhs.sortOrder
            }
            return lhs.name < rhs.name
        }
        self.profilesByID = Dictionary(uniqueKeysWithValues: self.profiles.map { ($0.id, $0) })
    }

    func profile(id: String?) -> DeviceProfileDefinition? {
        guard let id else { return nil }
        return profilesByID[id]
    }

    func profiles(for category: ProfileCategory) -> [DeviceProfileDefinition] {
        profiles.filter { $0.category == category }
    }
}

/// Centralizes the autoexclusion rules that turn a `DeviceProfile` into a coherent
/// device state. Called from the editor at edit time so impossible combinations are
/// not even expressible — instead of being silently corrected at read time.
enum DeviceProfileValidator {
    struct AppliedState: Equatable {
        var chargingStateAvailability: ChargingStateAvailability
        var supportsWiredCharging: Bool
        var supportsWirelessCharging: Bool
        var wirelessChargingProfile: WirelessChargingProfile
        var hasInternalSubject: Bool
    }

    /// Returns the canonical state for a freshly selected profile.
    /// Used both when the user picks a profile in the editor and when seeding
    /// new device defaults from a catalog entry.
    static func canonicalState(for profile: DeviceProfileDefinition) -> AppliedState {
        AppliedState(
            chargingStateAvailability: profile.capChargingStateAvailability,
            supportsWiredCharging: profile.capWiredCharging,
            supportsWirelessCharging: profile.capWirelessCharging,
            wirelessChargingProfile: profile.defaultWirelessChargingProfile
                ?? profile.capWirelessProfiles.first
                ?? .genericQi,
            hasInternalSubject: false
        )
    }

    /// Coerces a possibly-contradictory state to fit the profile's capabilities.
    /// Preserves user-set values where they are still allowed; otherwise falls
    /// back to canonical defaults.
    static func coerce(
        state: AppliedState,
        to profile: DeviceProfileDefinition
    ) -> AppliedState {
        var coerced = state
        coerced.supportsWiredCharging = state.supportsWiredCharging && profile.capWiredCharging
        coerced.supportsWirelessCharging = state.supportsWirelessCharging && profile.capWirelessCharging
        if !coerced.supportsWiredCharging && !coerced.supportsWirelessCharging {
            coerced.supportsWiredCharging = profile.capWiredCharging
            coerced.supportsWirelessCharging = profile.capWirelessCharging
        }
        coerced.chargingStateAvailability = profile.capChargingStateAvailability
        if !profile.capWirelessProfiles.contains(state.wirelessChargingProfile) {
            coerced.wirelessChargingProfile = profile.defaultWirelessChargingProfile
                ?? profile.capWirelessProfiles.first
                ?? .genericQi
        }
        if !profile.capHasInternalSubject {
            coerced.hasInternalSubject = false
        }
        return coerced
    }

    /// True when the editor should offer the user a toggle for wired charging.
    /// (False means the profile forbids wired entirely — hide the row.)
    static func allowsWiredToggle(_ profile: DeviceProfileDefinition) -> Bool {
        profile.capWiredCharging
    }

    static func allowsWirelessToggle(_ profile: DeviceProfileDefinition) -> Bool {
        profile.capWirelessCharging
    }

    /// True when both transports are permitted — meaning the user may opt out of
    /// either; otherwise the surviving transport is mandatory.
    static func allowsTransportChoice(_ profile: DeviceProfileDefinition) -> Bool {
        profile.capWiredCharging && profile.capWirelessCharging
    }

    /// True when there is more than one wireless profile to choose from for this
    /// catalog entry. Shown as a picker; otherwise hidden (single value implied).
    static func allowsWirelessProfileChoice(_ profile: DeviceProfileDefinition) -> Bool {
        profile.capWirelessProfiles.count > 1
    }

    /// True when the profile's `capChargingStateAvailability` is fixed to a single
    /// state mode — in which case the editor renders a locked badge instead of a picker.
    static func chargingStateIsLocked(_ profile: DeviceProfileDefinition) -> Bool {
        profile.capChargingStateAvailability == .onOnly
            || profile.capChargingStateAvailability == .offOnly
    }
}

struct ChargeCheckpointSummary: Identifiable, Hashable {
    let id: UUID
    let sessionID: UUID
    let chargedDeviceID: UUID
    let powerbankID: UUID?
    let batteryBarsValue: Int
    let timestamp: Date
    let batteryPercent: Double
    let measuredEnergyWh: Double
    let currentAmps: Double
    let voltageVolts: Double?
    let label: String?

    var flag: ChargeCheckpointFlag {
        ChargeCheckpointFlag.fromStoredLabel(label)
    }

    var subject: CheckpointSubject {
        powerbankID == nil ? .chargedDevice : .powerbank
    }
}

enum ChargeCheckpointFlag: String, CaseIterable {
    case initial
    case intermediate
    case final

    var title: String {
        switch self {
        case .initial:
            return "Initial"
        case .intermediate:
            return "Intermediate"
        case .final:
            return "Final"
        }
    }

    var anchorDescription: String {
        switch self {
        case .initial:
            return "initial checkpoint"
        case .intermediate:
            return "intermediate checkpoint"
        case .final:
            return "final checkpoint"
        }
    }

    static func fromStoredLabel(_ label: String?) -> ChargeCheckpointFlag {
        let normalized = label?
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .lowercased()

        switch normalized {
        case "initial", "start":
            return .initial
        case "final", "end":
            return .final
        case "intermediate", nil, "":
            return .intermediate
        default:
            return .intermediate
        }
    }
}

struct ChargeSessionSampleSummary: Identifiable, Hashable {
    let sessionID: UUID
    let chargedDeviceID: UUID
    let bucketIndex: Int
    let timestamp: Date
    let averageCurrentAmps: Double
    let averageVoltageVolts: Double?
    let averagePowerWatts: Double
    let measuredEnergyWh: Double
    let estimatedBatteryPercent: Double?
    let sampleCount: Int

    var id: String {
        "\(sessionID.uuidString)-\(bucketIndex)"
    }
}

struct ChargeSessionSummary: Identifiable, Hashable {
    let id: UUID
    let chargedDeviceID: UUID
    let chargedPowerbankID: UUID?
    let chargerID: UUID?
    let sourcePowerbankID: UUID?
    let meterMACAddress: String?
    let meterName: String?
    let meterModel: String?
    let startedAt: Date
    let endedAt: Date?
    let lastObservedAt: Date
    let pausedAt: Date?
    let status: ChargeSessionStatus
    let sourceMode: ChargeSessionSourceMode
    let chargingTransportMode: ChargingTransportMode
    let chargingStateMode: ChargingStateMode
    let autoStopEnabled: Bool
    let measuredEnergyWh: Double
    let effectiveBatteryEnergyWh: Double?
    let meterEnergyBaselineWh: Double?
    let meterDurationBaselineSeconds: Double?
    let meterLastDurationSeconds: Double?
    let minimumObservedCurrentAmps: Double?
    let maximumObservedCurrentAmps: Double?
    let maximumObservedPowerWatts: Double?
    let maximumObservedVoltageVolts: Double?
    let hasObservedChargeFlow: Bool
    let selectedSourceVoltageVolts: Double?
    let completionCurrentAmps: Double?
    let stopThresholdAmps: Double
    let startBatteryPercent: Double?
    let endBatteryPercent: Double?
    let capacityEstimateWh: Double?
    let wirelessEfficiencyFactor: Double?
    let usesEstimatedWirelessEfficiency: Bool
    let shouldWarnAboutLowWirelessEfficiency: Bool
    let supportsChargingWhileOff: Bool
    let usedOfflineMeterCounters: Bool
    let targetBatteryPercent: Double?
    let targetBatteryAlertTriggeredAt: Date?
    let requiresCompletionConfirmation: Bool
    let completionConfirmationRequestedAt: Date?
    let completionContradictionPercent: Double?
    let selectedDataGroup: UInt8?
    let trimStart: Date?
    let trimEnd: Date?
    let wasConflictHealed: Bool
    let checkpoints: [ChargeCheckpointSummary]
    let aggregatedSamples: [ChargeSessionSampleSummary]

    var effectiveTrimStart: Date { trimStart ?? startedAt }
    var effectiveTrimEnd: Date { trimEnd ?? (endedAt ?? lastObservedAt) }
    var isTrimmed: Bool { trimStart != nil || trimEnd != nil }
    var effectiveTimeRange: ClosedRange<Date> {
        let start = effectiveTrimStart
        let end = max(effectiveTrimEnd, start)
        return start...end
    }
    var displayedAggregatedSamples: [ChargeSessionSampleSummary] {
        guard isTrimmed else { return aggregatedSamples }
        let range = effectiveTimeRange
        return aggregatedSamples.filter { range.contains($0.timestamp) }
    }

    var sessionKind: ChargeSessionKind {
        ChargeSessionKind(
            chargingTransportMode: chargingTransportMode,
            chargingStateMode: chargingStateMode
        )
    }

    /// Generalized source slot. `none` when no source is tracked, `charger(id)` for the existing
    /// charger flow, `powerbank(id)` when a powerbank is supplying power for this session.
    var source: ChargeSessionSource {
        if let sourcePowerbankID {
            return .powerbank(sourcePowerbankID)
        }
        if let chargerID {
            return .charger(chargerID)
        }
        return .none
    }

    var hasPowerbankSubject: Bool { chargedPowerbankID != nil }
    var hasPowerbankSource: Bool { sourcePowerbankID != nil }

    var duration: TimeInterval {
        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
    }

    var meterObservedDuration: TimeInterval? {
        guard let meterDurationBaselineSeconds, let meterLastDurationSeconds else {
            return nil
        }
        guard meterLastDurationSeconds >= meterDurationBaselineSeconds else {
            return nil
        }
        return meterLastDurationSeconds - meterDurationBaselineSeconds
    }

    var effectiveDuration: TimeInterval {
        if isTrimmed {
            return max(effectiveTrimEnd.timeIntervalSince(effectiveTrimStart), 0)
        }

        // Use timestamp-based duration as primary source; only use meter counter if it's consistent
        let timestampDuration = duration

        if let meterDuration = meterObservedDuration {
            // Allow 5% tolerance for meter counter vs timestamp calculation
            let tolerance = timestampDuration * 0.05
            let lower = timestampDuration - tolerance
            let upper = timestampDuration + tolerance

            // If meter duration is within tolerance range, use it (more precise)
            // Otherwise fall back to timestamp-based duration
            if meterDuration >= lower && meterDuration <= upper {
                return meterDuration
            }
        }

        return timestampDuration
    }

    var effectiveOrMeasuredEnergyWh: Double {
        effectiveBatteryEnergyWh ?? measuredEnergyWh
    }

    var hasSavableChargeData: Bool {
        hasObservedChargeFlow
            || measuredEnergyWh > 0
            || (maximumObservedCurrentAmps ?? 0) > 0
            || (maximumObservedPowerWatts ?? 0) > 0
            || !aggregatedSamples.isEmpty
    }

    var batteryDeltaPercent: Double? {
        guard let startBatteryPercent, let endBatteryPercent,
              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
        return endBatteryPercent - startBatteryPercent
    }

    var startsFromFlatBattery: Bool {
        guard let startBatteryPercent else {
            return false
        }
        return startBatteryPercent.isFinite && startBatteryPercent < 0
    }

    var canAutoStop: Bool {
        autoStopEnabled && stopThresholdAmps > 0
    }

    var isPaused: Bool {
        status == .paused
    }

    var isOpen: Bool {
        status.isOpen
    }
}

enum BatteryLevelPredictionBasis: Hashable {
    case capacityEstimate
    case checkpointEnergyMap
    case typicalChargeCurve

    var metricLabel: String {
        switch self {
        case .capacityEstimate:
            return "est. capacity"
        case .checkpointEnergyMap:
            return "energy map"
        case .typicalChargeCurve:
            return "charge curve"
        }
    }

    var explanatoryLabel: String {
        switch self {
        case .capacityEstimate:
            return "estimated capacity"
        case .checkpointEnergyMap:
            return "checkpoint energy map"
        case .typicalChargeCurve:
            return "typical charge curve"
        }
    }
}

struct BatteryLevelPrediction: Hashable {
    let predictedPercent: Double
    let estimatedCapacityWh: Double?
    let basis: BatteryLevelPredictionBasis
    let anchorPercent: Double
    let anchorEnergyWh: Double
    let anchorDescription: String

    func energyWh(forPercent percent: Double) -> Double? {
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
            return nil
        }

        return anchorEnergyWh + ((percent - anchorPercent) / 100) * estimatedCapacityWh
    }
}

enum BatteryLevelPredictionTuning {
    static func inferredVirtualZeroEnergyWh(
        from anchors: [BatteryLevelPredictionAnchor],
        estimatedCapacityWh: Double? = nil,
        historicalReserveEnergyWh: Double? = nil
    ) -> Double? {
        let sortedAnchors = anchors
            .filter { $0.percent > 0 && $0.percent <= 100 && $0.energyWh >= 0 }
            .sorted { lhs, rhs in
                if lhs.energyWh != rhs.energyWh {
                    return lhs.energyWh < rhs.energyWh
                }
                return lhs.timestamp < rhs.timestamp
            }

        guard let firstAnchor = sortedAnchors.first else {
            return nil
        }

        func clampedReserve(_ reserveEnergyWh: Double) -> Double? {
            guard reserveEnergyWh.isFinite else {
                return nil
            }
            return min(max(reserveEnergyWh, 0), firstAnchor.energyWh)
        }

        if let historicalReserveEnergyWh,
           let reserveEnergyWh = clampedReserve(historicalReserveEnergyWh) {
            return reserveEnergyWh
        }

        if let estimatedCapacityWh,
           estimatedCapacityWh > 0 {
            return clampedReserve(
                firstAnchor.energyWh - ((firstAnchor.percent / 100) * estimatedCapacityWh)
            )
        }

        var zeroCandidates: [Double] = []

        for lowerIndex in sortedAnchors.indices {
            for upperIndex in sortedAnchors.indices where upperIndex > lowerIndex {
                let lower = sortedAnchors[lowerIndex]
                let upper = sortedAnchors[upperIndex]
                let percentDelta = upper.percent - lower.percent
                let energyDeltaWh = upper.energyWh - lower.energyWh

                guard percentDelta >= 3, energyDeltaWh > 0.01 else {
                    continue
                }

                let capacityWh = energyDeltaWh / (percentDelta / 100)
                guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
                    continue
                }

                zeroCandidates.append(lower.energyWh - ((lower.percent / 100) * capacityWh))
                zeroCandidates.append(upper.energyWh - ((upper.percent / 100) * capacityWh))
            }
        }

        guard !zeroCandidates.isEmpty else {
            return nil
        }

        let sortedCandidates = zeroCandidates.sorted()
        return clampedReserve(sortedCandidates[sortedCandidates.count / 2])
    }

    static func predictedPercent(
        anchorPercent: Double,
        anchorEnergyWh: Double,
        anchorTimestamp: Date,
        anchorIsCheckpoint: Bool,
        effectiveEnergyWh: Double,
        referenceTimestamp: Date,
        estimatedCapacityWh: Double
    ) -> Double {
        _ = anchorTimestamp
        _ = anchorIsCheckpoint
        _ = referenceTimestamp

        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100

        return min(
            100,
            max(
                0,
                anchorPercent + rawGainPercent
            )
        )
    }

    static func predictedPercent(
        anchorPercent: Double,
        anchorEnergyWh: Double,
        effectiveEnergyWh: Double,
        chargeCurve: BatteryChargeCurve,
        deviationFactor: Double?
    ) -> Double? {
        guard
            let curveAnchorEnergyWh = chargeCurve.energyWh(forPercent: anchorPercent)
        else {
            return nil
        }

        let sessionEnergyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
        let normalizedEnergyDeltaWh = sessionEnergyDeltaWh / max(deviationFactor ?? 1, 0.05)
        let projectedCurveEnergyWh = curveAnchorEnergyWh + normalizedEnergyDeltaWh

        guard let curvePercent = chargeCurve.percent(forEnergyWh: projectedCurveEnergyWh) else {
            return nil
        }

        return min(100, max(anchorPercent, curvePercent))
    }

    static func deviationFactor(
        anchors: [BatteryLevelPredictionAnchor],
        chargeCurve: BatteryChargeCurve
    ) -> Double? {
        let sortedAnchors = anchors.sorted { lhs, rhs in
            if lhs.timestamp != rhs.timestamp {
                return lhs.timestamp < rhs.timestamp
            }
            return lhs.energyWh < rhs.energyWh
        }
        var ratios: [Double] = []

        for lowerIndex in sortedAnchors.indices {
            for upperIndex in sortedAnchors.indices where upperIndex > lowerIndex {
                let lower = sortedAnchors[lowerIndex]
                let upper = sortedAnchors[upperIndex]
                let percentDelta = upper.percent - lower.percent
                let energyDeltaWh = upper.energyWh - lower.energyWh

                guard percentDelta >= 3, energyDeltaWh > 0.01,
                      let curveLowerEnergyWh = chargeCurve.energyWh(forPercent: lower.percent),
                      let curveUpperEnergyWh = chargeCurve.energyWh(forPercent: upper.percent) else {
                    continue
                }

                let curveEnergyDeltaWh = curveUpperEnergyWh - curveLowerEnergyWh
                guard curveEnergyDeltaWh > 0.01 else {
                    continue
                }

                let ratio = energyDeltaWh / curveEnergyDeltaWh
                guard ratio.isFinite, ratio > 0 else {
                    continue
                }

                ratios.append(min(max(ratio, 0.25), 4.0))
            }
        }

        guard !ratios.isEmpty else {
            return nil
        }

        let sortedRatios = ratios.sorted()
        return sortedRatios[sortedRatios.count / 2]
    }
}

struct CapacityTrendPoint: Identifiable, Hashable {
    let sessionID: UUID
    let timestamp: Date
    let capacityWh: Double
    let chargingTransportMode: ChargingTransportMode

    var id: UUID { sessionID }
}

struct TypicalChargeCurvePoint: Identifiable, Hashable {
    let percentBin: Int
    let averageEnergyWh: Double
    let sampleCount: Int

    var id: Int { percentBin }
}

struct BatteryLevelPredictionAnchor: Hashable {
    let percent: Double
    let energyWh: Double
    let timestamp: Date
    let description: String
    let isCheckpoint: Bool

    init(
        percent: Double,
        energyWh: Double,
        timestamp: Date,
        description: String = "",
        isCheckpoint: Bool
    ) {
        self.percent = percent
        self.energyWh = energyWh
        self.timestamp = timestamp
        self.description = description
        self.isCheckpoint = isCheckpoint
    }
}

struct BatteryChargeCurve {
    private let points: [(percent: Double, energyWh: Double)]

    init?(typicalCurvePoints: [TypicalChargeCurvePoint]) {
        let validPoints = typicalCurvePoints
            .filter {
                $0.averageEnergyWh.isFinite
                    && $0.averageEnergyWh >= 0
                    && $0.percentBin >= 0
                    && $0.percentBin <= 100
            }
            .sorted { lhs, rhs in
                lhs.percentBin < rhs.percentBin
            }

        var normalizedPoints: [(percent: Double, energyWh: Double)] = []
        var runningMaximumEnergyWh = 0.0

        for point in validPoints {
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
            normalizedPoints.append(
                (percent: Double(point.percentBin), energyWh: runningMaximumEnergyWh)
            )
        }

        guard normalizedPoints.count >= 2 else {
            return nil
        }

        self.points = normalizedPoints
    }

    func energyWh(forPercent percent: Double) -> Double? {
        interpolatedValue(
            lookup: min(max(percent, 0), 100),
            key: { $0.percent },
            value: { $0.energyWh }
        )
    }

    func percent(forEnergyWh energyWh: Double) -> Double? {
        interpolatedValue(
            lookup: max(energyWh, 0),
            key: { $0.energyWh },
            value: { $0.percent }
        )
    }

    private func interpolatedValue(
        lookup: Double,
        key: ((percent: Double, energyWh: Double)) -> Double,
        value: ((percent: Double, energyWh: Double)) -> Double
    ) -> Double? {
        guard let first = points.first, let last = points.last else {
            return nil
        }

        let firstKey = key(first)
        let lastKey = key(last)
        guard lookup >= firstKey, lookup <= lastKey else {
            return nil
        }

        if abs(lookup - firstKey) < 0.000_1 {
            return value(first)
        }
        if abs(lookup - lastKey) < 0.000_1 {
            return value(last)
        }

        guard let upperIndex = points.firstIndex(where: { key($0) >= lookup }),
              upperIndex > 0 else {
            return nil
        }

        let lower = points[upperIndex - 1]
        let upper = points[upperIndex]
        let lowerKey = key(lower)
        let upperKey = key(upper)
        let span = upperKey - lowerKey
        guard span > 0.000_1 else {
            return value(upper)
        }

        let progress = (lookup - lowerKey) / span
        return value(lower) + ((value(upper) - value(lower)) * progress)
    }
}

struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
    let timestamp: Date
    let powerWatts: Double
    let currentAmps: Double
    let voltageVolts: Double

    var id: TimeInterval {
        timestamp.timeIntervalSince1970
    }
}

struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
    let index: Int
    let lowerBoundWatts: Double
    let upperBoundWatts: Double
    let count: Int
    let relativeFrequency: Double

    var id: Int { index }
}

enum HistogramResolution: Int, CaseIterable, Identifiable {
    case x1 = 1
    case x2 = 2
    case x4 = 4

    var id: Int { rawValue }

    var label: String {
        switch self {
        case .x1: return "1×"
        case .x2: return "2×"
        case .x4: return "4×"
        }
    }
}

struct ChargerStandbyPowerMeasurementStatistics: Hashable {
    let sampleCount: Int
    let observedDuration: TimeInterval
    let averagePowerWatts: Double
    let recentAveragePowerWatts: Double
    let medianPowerWatts: Double
    let minimumPowerWatts: Double
    let maximumPowerWatts: Double
    let standardDeviationPowerWatts: Double
    let coefficientOfVariation: Double
    let averageCurrentAmps: Double
    let averageVoltageVolts: Double
    let stabilityDeltaWatts: Double
    let stabilityToleranceWatts: Double
    let histogram: [ChargerStandbyPowerDistributionBin]

    var projectedDailyEnergyWh: Double {
        averagePowerWatts * 24
    }

    var projectedWeeklyEnergyWh: Double {
        averagePowerWatts * 24 * 7
    }

    var projectedMonthlyEnergyWh: Double {
        averagePowerWatts * 24 * 30
    }

    var projectedYearlyEnergyWh: Double {
        averagePowerWatts * 24 * 365
    }

    var stabilityDeltaMilliwatts: Double {
        stabilityDeltaWatts * 1000
    }

    var isStable: Bool {
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
        && stabilityDeltaWatts <= stabilityToleranceWatts
    }
}

struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
    let id: UUID
    let chargerID: UUID
    let meterMACAddress: String
    let meterName: String?
    let meterModel: String?
    let startedAt: Date
    let endedAt: Date
    let sampleCount: Int
    let stabilizedAt: Date?
    let averagePowerWatts: Double
    let recentAveragePowerWatts: Double
    let medianPowerWatts: Double
    let minimumPowerWatts: Double
    let maximumPowerWatts: Double
    let standardDeviationPowerWatts: Double
    let coefficientOfVariation: Double
    let averageCurrentAmps: Double
    let averageVoltageVolts: Double
    let stabilityDeltaWatts: Double
    let stabilityToleranceWatts: Double
    /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display.
    let storedHistogram: [ChargerStandbyPowerDistributionBin]

    // MARK: - Codable (with migration from legacy powerSamplesWatts)

    private enum CodingKeys: String, CodingKey {
        case id, chargerID, meterMACAddress, meterName, meterModel
        case startedAt, endedAt, sampleCount, stabilizedAt
        case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts
        case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts
        case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts
        case stabilityDeltaWatts, stabilityToleranceWatts
        case storedHistogram
        case powerSamplesWatts // legacy – decode only
    }

    init(
        id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?,
        startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?,
        averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double,
        minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double,
        coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double,
        stabilityDeltaWatts: Double, stabilityToleranceWatts: Double,
        storedHistogram: [ChargerStandbyPowerDistributionBin]
    ) {
        self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress
        self.meterName = meterName; self.meterModel = meterModel
        self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount
        self.stabilizedAt = stabilizedAt
        self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts
        self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts
        self.maximumPowerWatts = maximumPowerWatts
        self.standardDeviationPowerWatts = standardDeviationPowerWatts
        self.coefficientOfVariation = coefficientOfVariation
        self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts
        self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts
        self.storedHistogram = storedHistogram
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(UUID.self, forKey: .id)
        chargerID = try c.decode(UUID.self, forKey: .chargerID)
        meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress)
        meterName = try c.decodeIfPresent(String.self, forKey: .meterName)
        meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel)
        startedAt = try c.decode(Date.self, forKey: .startedAt)
        endedAt = try c.decode(Date.self, forKey: .endedAt)
        sampleCount = try c.decode(Int.self, forKey: .sampleCount)
        stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt)
        averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts)
        recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts)
        medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts)
        minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts)
        maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts)
        standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts)
        coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation)
        averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps)
        averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts)
        stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts)
        stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts)

        let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram)
        if let decodedBins, !decodedBins.isEmpty {
            storedHistogram = decodedBins
        } else {
            // Migrate from legacy raw samples format
            let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? []
            let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded())))
            storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram(
                for: samples,
                preferredBinCount: base * HistogramResolution.x4.rawValue
            )
        }
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(id, forKey: .id)
        try c.encode(chargerID, forKey: .chargerID)
        try c.encode(meterMACAddress, forKey: .meterMACAddress)
        try c.encodeIfPresent(meterName, forKey: .meterName)
        try c.encodeIfPresent(meterModel, forKey: .meterModel)
        try c.encode(startedAt, forKey: .startedAt)
        try c.encode(endedAt, forKey: .endedAt)
        try c.encode(sampleCount, forKey: .sampleCount)
        try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt)
        try c.encode(averagePowerWatts, forKey: .averagePowerWatts)
        try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts)
        try c.encode(medianPowerWatts, forKey: .medianPowerWatts)
        try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts)
        try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts)
        try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts)
        try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation)
        try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps)
        try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts)
        try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts)
        try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts)
        try c.encode(storedHistogram, forKey: .storedHistogram)
    }

    // MARK: - Computed

    var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
    var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
    var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
    var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
    var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
    var isStable: Bool { stabilizedAt != nil }

    /// Returns the histogram downsampled to the requested resolution.
    /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue.
    func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] {
        let factor = HistogramResolution.x4.rawValue / resolution.rawValue
        return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor)
    }
}

enum ChargerStandbyPowerMeasurementAnalyzer {
    static let minimumStableSampleCount = 45
    static let recentSampleWindow = 40
    static let minimumStabilityToleranceWatts = 0.010
    static let relativeStabilityTolerance = 0.05

    static func statistics(
        from samples: [ChargerStandbyPowerSample],
        startedAt: Date,
        referenceDate: Date = Date()
    ) -> ChargerStandbyPowerMeasurementStatistics? {
        guard !samples.isEmpty else {
            return nil
        }

        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)

        guard powerValues.isEmpty == false else {
            return nil
        }

        let averagePower = mean(powerValues)
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
        let stabilityDelta = abs(averagePower - recentAveragePower)
        let stabilityTolerance = max(
            minimumStabilityToleranceWatts,
            abs(averagePower) * relativeStabilityTolerance
        )

        let baseBinCount = min(18, max(8, Int(Double(powerValues.count).squareRoot().rounded())))
        let liveHistogram = histogram(for: powerValues, preferredBinCount: baseBinCount * HistogramResolution.x4.rawValue)

        return ChargerStandbyPowerMeasurementStatistics(
            sampleCount: powerValues.count,
            observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
            averagePowerWatts: averagePower,
            recentAveragePowerWatts: recentAveragePower,
            medianPowerWatts: median(powerValues),
            minimumPowerWatts: powerValues.min() ?? 0,
            maximumPowerWatts: powerValues.max() ?? 0,
            standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower),
            coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower),
            averageCurrentAmps: mean(currentValues),
            averageVoltageVolts: mean(voltageValues),
            stabilityDeltaWatts: stabilityDelta,
            stabilityToleranceWatts: stabilityTolerance,
            histogram: liveHistogram
        )
    }

    static func measurementSummary(
        chargerID: UUID,
        meterMACAddress: String,
        meterName: String?,
        meterModel: String?,
        startedAt: Date,
        endedAt: Date,
        samples: [ChargerStandbyPowerSample],
        stabilizedAt: Date?
    ) -> ChargerStandbyPowerMeasurementSummary? {
        guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
            return nil
        }

        return ChargerStandbyPowerMeasurementSummary(
            id: UUID(),
            chargerID: chargerID,
            meterMACAddress: meterMACAddress,
            meterName: meterName,
            meterModel: meterModel,
            startedAt: startedAt,
            endedAt: endedAt,
            sampleCount: statistics.sampleCount,
            stabilizedAt: stabilizedAt,
            averagePowerWatts: statistics.averagePowerWatts,
            recentAveragePowerWatts: statistics.recentAveragePowerWatts,
            medianPowerWatts: statistics.medianPowerWatts,
            minimumPowerWatts: statistics.minimumPowerWatts,
            maximumPowerWatts: statistics.maximumPowerWatts,
            standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
            coefficientOfVariation: statistics.coefficientOfVariation,
            averageCurrentAmps: statistics.averageCurrentAmps,
            averageVoltageVolts: statistics.averageVoltageVolts,
            stabilityDeltaWatts: statistics.stabilityDeltaWatts,
            stabilityToleranceWatts: statistics.stabilityToleranceWatts,
            storedHistogram: statistics.histogram
        )
    }

    /// Merges consecutive bins in groups of `factor`. Returns the input unchanged when factor ≤ 1.
    static func downsample(
        _ bins: [ChargerStandbyPowerDistributionBin],
        factor: Int
    ) -> [ChargerStandbyPowerDistributionBin] {
        guard factor > 1, !bins.isEmpty else { return bins }
        let totalCount = bins.reduce(0) { $0 + $1.count }
        var result: [ChargerStandbyPowerDistributionBin] = []
        var inputIndex = 0
        var outputIndex = 0
        while inputIndex < bins.count {
            let group = Array(bins[inputIndex..<min(inputIndex + factor, bins.count)])
            let mergedCount = group.reduce(0) { $0 + $1.count }
            let relFreq = totalCount > 0 ? Double(mergedCount) / Double(totalCount) : 0
            result.append(ChargerStandbyPowerDistributionBin(
                index: outputIndex,
                lowerBoundWatts: group.first!.lowerBoundWatts,
                upperBoundWatts: group.last!.upperBoundWatts,
                count: mergedCount,
                relativeFrequency: relFreq
            ))
            inputIndex += factor
            outputIndex += 1
        }
        return result
    }

    static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
        let finiteValues = values.filter(\.isFinite)
        guard finiteValues.isEmpty == false else {
            return []
        }

        let minimum = finiteValues.min() ?? 0
        let maximum = finiteValues.max() ?? 0
        let spread = maximum - minimum
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))

        guard spread > 0 else {
            return [
                ChargerStandbyPowerDistributionBin(
                    index: 0,
                    lowerBoundWatts: minimum,
                    upperBoundWatts: maximum,
                    count: finiteValues.count,
                    relativeFrequency: 1
                )
            ]
        }

        let safeBinCount = max(1, binCount)
        let binWidth = spread / Double(safeBinCount)
        var counts = Array(repeating: 0, count: safeBinCount)

        for value in finiteValues {
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
            counts[safeIndex] += 1
        }

        return counts.enumerated().map { index, count in
            let lowerBound = minimum + (Double(index) * binWidth)
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth

            return ChargerStandbyPowerDistributionBin(
                index: index,
                lowerBoundWatts: lowerBound,
                upperBoundWatts: upperBound,
                count: count,
                relativeFrequency: Double(count) / Double(finiteValues.count)
            )
        }
    }

    private static func mean(_ values: [Double]) -> Double {
        guard values.isEmpty == false else {
            return 0
        }
        return values.reduce(0, +) / Double(values.count)
    }

    private static func median(_ values: [Double]) -> Double {
        guard values.isEmpty == false else {
            return 0
        }

        let sorted = values.sorted()
        let middleIndex = sorted.count / 2

        if sorted.count.isMultiple(of: 2) {
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
        }

        return sorted[middleIndex]
    }

    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
        guard values.count > 1 else {
            return 0
        }

        let variance = values.reduce(0) { partialResult, value in
            let delta = value - mean
            return partialResult + (delta * delta)
        } / Double(values.count)

        return variance.squareRoot()
    }

    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
        guard abs(mean) > 0.000_001 else {
            return 0
        }

        return standardDeviation(values, mean: mean) / abs(mean)
    }
}

struct ChargedDeviceSummary: Identifiable, Hashable {
    let id: UUID
    let qrIdentifier: String
    let name: String
    let deviceClass: ChargedDeviceClass
    let deviceTemplateID: String?
    let templateDefinition: ChargedDeviceTemplateDefinition?
    let profileID: String?
    let hasInternalSubject: Bool
    let supportsChargingWhileOff: Bool
    let chargingStateAvailability: ChargingStateAvailability
    let supportsWiredCharging: Bool
    let supportsWirelessCharging: Bool
    let chargerType: ChargerType?
    let wirelessChargingProfile: WirelessChargingProfile
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
    let wirelessChargerEfficiencyFactor: Double?
    let wiredChargeCompletionCurrentAmps: Double?
    let wirelessChargeCompletionCurrentAmps: Double?
    let chargerObservedVoltageSelections: [Double]
    let chargerIdleCurrentAmps: Double?
    let chargerEfficiencyFactor: Double?
    let chargerMaximumPowerWatts: Double?
    let notes: String?
    let minimumCurrentAmps: Double?
    let estimatedBatteryCapacityWh: Double?
    let wiredMinimumCurrentAmps: Double?
    let wirelessMinimumCurrentAmps: Double?
    let wiredEstimatedBatteryCapacityWh: Double?
    let wirelessEstimatedBatteryCapacityWh: Double?
    let createdAt: Date
    let updatedAt: Date
    let sessions: [ChargeSessionSummary]
    let capacityHistory: [CapacityTrendPoint]
    let typicalCurve: [TypicalChargeCurvePoint]
    let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]

    var isCharger: Bool {
        deviceClass == .charger
    }

    /// True when the device's active catalog profile is one of the case-style
    /// profiles (AirPods case, charging case, …) — i.e. the editor exposes the
    /// `hasInternalSubject` toggle and the detail UI should surface its state.
    var supportsInternalSubject: Bool {
        guard let profileID,
              let profile = DeviceProfileCatalog.shared.profile(id: profileID) else {
            return false
        }
        return profile.capHasInternalSubject
    }

    var kind: ChargedDeviceKind {
        deviceClass.kind
    }

    var identityTitle: String {
        templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title)
    }

    var fallbackIdentitySymbolName: String {
        isCharger ? kind.symbolName : deviceClass.symbolName
    }

    var identityIcon: ChargedDeviceTemplateIcon {
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
    }

    var identitySymbolName: String {
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
    }

    var activeSession: ChargeSessionSummary? {
        sessions.first(where: \.isOpen)
    }

    var recentCompletedSessions: [ChargeSessionSummary] {
        sessions.filter { $0.status == .completed }
    }

    var sessionCount: Int {
        sessions.count
    }

    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
        standbyPowerMeasurements.first
    }

    var supportedChargingModes: [ChargingTransportMode] {
        var modes: [ChargingTransportMode] = []
        if supportsWiredCharging {
            modes.append(.wired)
        }
        if supportsWirelessCharging {
            modes.append(.wireless)
        }
        return modes
    }

    var supportedChargingStateModes: [ChargingStateMode] {
        chargingStateAvailability.supportedModes
    }

    var hasMultipleChargingTransports: Bool {
        supportedChargingModes.count > 1
    }

    var hasMultipleChargingStateModes: Bool {
        supportedChargingStateModes.count > 1
    }

    var showsWirelessProfileDetails: Bool {
        supportsWirelessCharging
            && hasMultipleChargingTransports
            && deviceClass != .watch
    }

    var chargingSupportSummary: String {
        switch (supportsWiredCharging, supportsWirelessCharging) {
        case (true, true):
            return "Supports wired and wireless charging."
        case (true, false):
            return "Supports wired charging only."
        case (false, true):
            return "Supports wireless charging only."
        case (false, false):
            return "No charging method configured."
        }
    }

    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
        if let matchingSession = sessions.first(where: {
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
        }) {
            return matchingSession.chargingStateMode
        }
        return chargingStateAvailability.supportedModes.first ?? .on
    }

    func sessionKind(
        for chargingTransportMode: ChargingTransportMode,
        chargingStateMode: ChargingStateMode? = nil
    ) -> ChargeSessionKind {
        ChargeSessionKind(
            chargingTransportMode: chargingTransportMode,
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
        )
    }

    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
        switch chargingTransportMode {
        case .wired:
            return wiredEstimatedBatteryCapacityWh
        case .wireless:
            return wirelessEstimatedBatteryCapacityWh
        }
    }

    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
        switch chargingTransportMode {
        case .wired:
            return wiredMinimumCurrentAmps
        case .wireless:
            return wirelessMinimumCurrentAmps
        }
    }

    func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
        hasMultipleChargingTransports
            || supportedChargingModes.contains(chargingTransportMode) == false
    }

    func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
        hasMultipleChargingStateModes
            || supportedChargingStateModes.contains(chargingStateMode) == false
    }

    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
            return explicitCurrent
        }

        switch sessionKind.chargingTransportMode {
        case .wired:
            return wiredChargeCompletionCurrentAmps
        case .wireless:
            return wirelessChargeCompletionCurrentAmps
        }
    }

    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
            return learnedCurrent
        }

        switch sessionKind.chargingTransportMode {
        case .wired:
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
        case .wireless:
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
        }
    }

    func resolvedCompletionCurrentAmps(
        for chargingTransportMode: ChargingTransportMode,
        chargingStateMode: ChargingStateMode? = nil
    ) -> Double? {
        let sessionKind = sessionKind(
            for: chargingTransportMode,
            chargingStateMode: chargingStateMode
        )

        return configuredCompletionCurrentAmps(for: sessionKind)
            ?? learnedCompletionCurrentAmps(for: sessionKind)
            ?? minimumCurrentAmps(for: chargingTransportMode)
            ?? minimumCurrentAmps
    }

    func batteryLevelPrediction(
        for session: ChargeSessionSummary,
        effectiveEnergyWhOverride: Double? = nil,
        referenceTimestamp: Date? = nil
    ) -> BatteryLevelPrediction? {
        let estimatedCapacityWh = session.capacityEstimateWh
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
            ?? estimatedBatteryCapacityWh

        let effectiveEnergyWh = effectiveEnergyWhOverride
            ?? session.effectiveBatteryEnergyWh
            ?? session.measuredEnergyWh

        func anchorCapacityCandidates(from anchors: [BatteryLevelPredictionAnchor]) -> [Double] {
            var candidates: [Double] = []

            for lowerIndex in anchors.indices {
                for upperIndex in anchors.indices where upperIndex > lowerIndex {
                    let lower = anchors[lowerIndex]
                    let upper = anchors[upperIndex]
                    let percentDelta = upper.percent - lower.percent
                    let energyDelta = upper.energyWh - lower.energyWh

                    guard percentDelta >= 3, energyDelta > 0.01 else {
                        continue
                    }

                    let capacityWh = energyDelta / (percentDelta / 100)
                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
                        continue
                    }

                    candidates.append(capacityWh)
                }
            }

            return candidates
        }

        func inferredCheckpointEnergyMapCapacityWh(from anchors: [BatteryLevelPredictionAnchor]) -> Double? {
            let candidates = anchorCapacityCandidates(from: anchors)
            guard !candidates.isEmpty else {
                return nil
            }

            let sortedCandidates = candidates.sorted()
            return sortedCandidates[sortedCandidates.count / 2]
        }

        var anchors: [BatteryLevelPredictionAnchor] = []

        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
            anchors.append(
                BatteryLevelPredictionAnchor(
                    percent: startBatteryPercent,
                    energyWh: 0,
                    timestamp: session.effectiveTrimStart,
                    description: "session start",
                    isCheckpoint: false
                )
            )
        }

        anchors.append(
            contentsOf: session.checkpoints
                .filter { $0.batteryPercent >= 0 }
                .map { checkpoint in
                    BatteryLevelPredictionAnchor(
                        percent: checkpoint.batteryPercent,
                        energyWh: checkpoint.measuredEnergyWh,
                        timestamp: checkpoint.timestamp,
                        description: checkpoint.flag.anchorDescription,
                        isCheckpoint: true
                    )
                }
        )

        if session.startsFromFlatBattery {
            if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
                from: anchors,
                estimatedCapacityWh: estimatedCapacityWh,
                historicalReserveEnergyWh: estimatedFlatReserveEnergyWh(excluding: session.id)
            ) {
                anchors.append(
                    BatteryLevelPredictionAnchor(
                        percent: 0,
                        energyWh: virtualZeroEnergyWh,
                        timestamp: session.effectiveTrimStart,
                        description: "estimated flat reserve",
                        isCheckpoint: false
                    )
                )
            } else if let firstCheckpoint = anchors.min(by: { $0.energyWh < $1.energyWh }),
                      effectiveEnergyWh < firstCheckpoint.energyWh - 0.05 {
                return nil
            }
        }

        let sortedAnchors = anchors.sorted { lhs, rhs in
            if lhs.energyWh != rhs.energyWh {
                return lhs.energyWh < rhs.energyWh
            }
            return lhs.timestamp < rhs.timestamp
        }

        guard !sortedAnchors.isEmpty else {
            return nil
        }

        let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
        let inferredCapacityWh = estimatedCapacityWh
            ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
        let fallbackBasis: BatteryLevelPredictionBasis = estimatedCapacityWh == nil
            ? .checkpointEnergyMap
            : .capacityEstimate

        let lowerAnchor = sortedAnchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
        let upperAnchor = sortedAnchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!

        let predictedPercent: Double
        let basis: BatteryLevelPredictionBasis
        if let lowerAnchor,
           let upperAnchor,
           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
            let interpolationProgress = min(
                max(
                    (effectiveEnergyWh - lowerAnchor.energyWh) /
                    (upperAnchor.energyWh - lowerAnchor.energyWh),
                    0
                ),
                1
            )
            predictedPercent = min(
                max(
                    lowerAnchor.percent +
                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
                    0
                ),
                100
            )
            basis = fallbackBasis
        } else {
            let chargeCurve = BatteryChargeCurve(typicalCurvePoints: typicalCurve)
            let curveDeviationFactor = chargeCurve.flatMap {
                BatteryLevelPredictionTuning.deviationFactor(
                    anchors: sortedAnchors,
                    chargeCurve: $0
                )
            }
            let curvePredictedPercent = chargeCurve.flatMap {
                BatteryLevelPredictionTuning.predictedPercent(
                    anchorPercent: anchor.percent,
                    anchorEnergyWh: anchor.energyWh,
                    effectiveEnergyWh: effectiveEnergyWh,
                    chargeCurve: $0,
                    deviationFactor: curveDeviationFactor
                )
            }

            if let curvePredictedPercent {
                predictedPercent = curvePredictedPercent
                basis = .typicalChargeCurve
            } else {
                guard let inferredCapacityWh, inferredCapacityWh > 0 else {
                    return nil
                }

                predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
                    anchorPercent: anchor.percent,
                    anchorEnergyWh: anchor.energyWh,
                    anchorTimestamp: anchor.timestamp,
                    anchorIsCheckpoint: anchor.isCheckpoint,
                    effectiveEnergyWh: effectiveEnergyWh,
                    referenceTimestamp: referenceTimestamp ?? session.lastObservedAt,
                    estimatedCapacityWh: inferredCapacityWh
                )
                basis = fallbackBasis
            }
        }

        return BatteryLevelPrediction(
            predictedPercent: predictedPercent,
            estimatedCapacityWh: inferredCapacityWh,
            basis: basis,
            anchorPercent: anchor.percent,
            anchorEnergyWh: anchor.energyWh,
            anchorDescription: anchor.description
        )
    }

    private func estimatedFlatReserveEnergyWh(excluding excludedSessionID: UUID? = nil) -> Double? {
        let reserves = sessions.compactMap { session -> Double? in
            guard session.id != excludedSessionID,
                  session.status == .completed,
                  session.startsFromFlatBattery else {
                return nil
            }

            let anchors = session.checkpoints.map {
                BatteryLevelPredictionAnchor(
                    percent: $0.batteryPercent,
                    energyWh: $0.measuredEnergyWh,
                    timestamp: $0.timestamp,
                    description: $0.flag.anchorDescription,
                    isCheckpoint: true
                )
            }

            return BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
                from: anchors,
                estimatedCapacityWh: session.capacityEstimateWh
            )
        }

        guard !reserves.isEmpty else {
            return nil
        }

        let sortedReserves = reserves.sorted()
        return sortedReserves[sortedReserves.count / 2]
    }

    func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
        ChargedDeviceSummary(
            id: id,
            qrIdentifier: qrIdentifier,
            name: name,
            deviceClass: deviceClass,
            deviceTemplateID: deviceTemplateID,
            templateDefinition: templateDefinition,
            profileID: profileID,
            hasInternalSubject: hasInternalSubject,
            supportsChargingWhileOff: supportsChargingWhileOff,
            chargingStateAvailability: chargingStateAvailability,
            supportsWiredCharging: supportsWiredCharging,
            supportsWirelessCharging: supportsWirelessCharging,
            chargerType: chargerType,
            wirelessChargingProfile: wirelessChargingProfile,
            configuredCompletionCurrents: configuredCompletionCurrents,
            learnedCompletionCurrents: learnedCompletionCurrents,
            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
            chargerEfficiencyFactor: chargerEfficiencyFactor,
            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
            notes: notes,
            minimumCurrentAmps: minimumCurrentAmps,
            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
            createdAt: createdAt,
            updatedAt: updatedAt,
            sessions: sessions,
            capacityHistory: capacityHistory,
            typicalCurve: typicalCurve,
            standbyPowerMeasurements: measurements
        )
    }
}

struct ChargingMonitorSnapshot {
    let meterMACAddress: String
    let meterName: String
    let meterModel: String
    let observedAt: Date
    let voltageVolts: Double
    let currentAmps: Double
    let powerWatts: Double
    let selectedDataGroup: UInt8?
    let meterChargeCounterAh: Double?
    let meterEnergyCounterWh: Double?
    let meterRecordingDurationSeconds: TimeInterval?
    let fallbackStopThresholdAmps: Double
}

// MARK: - Powerbank

enum BatteryLevelReporting: String, CaseIterable, Identifiable, Codable {
    case percent
    case bars
    case fullOnly
    case none

    var id: String { rawValue }

    var title: String {
        switch self {
        case .percent: return "Percent"
        case .bars: return "Bars"
        case .fullOnly: return "Full only"
        case .none: return "Not reported"
        }
    }

    var description: String {
        switch self {
        case .percent:
            return "The powerbank reports battery level as 0–100%."
        case .bars:
            return "The powerbank reports battery level as discrete bars (e.g. 4 of 4)."
        case .fullOnly:
            return "The powerbank has a single LED that lights only when charging completes — there is no signal for any partial level."
        case .none:
            return "The powerbank does not report a battery level."
        }
    }

    var allowsCheckpoints: Bool {
        self != .none
    }
}

enum CheckpointSubject: String, Codable, Hashable {
    case chargedDevice
    case powerbank

    var title: String {
        switch self {
        case .chargedDevice: return "Device"
        case .powerbank: return "Powerbank"
        }
    }
}

enum ChargeSessionSource: Hashable {
    case none
    case charger(UUID)
    case powerbank(UUID)

    var chargerID: UUID? {
        if case .charger(let id) = self { return id }
        return nil
    }

    var powerbankID: UUID? {
        if case .powerbank(let id) = self { return id }
        return nil
    }

    var isTracked: Bool {
        if case .none = self { return false }
        return true
    }
}

struct PowerbankSummary: Identifiable, Hashable {
    let id: UUID
    let qrIdentifier: String
    let name: String
    let deviceTemplateID: String?
    let templateDefinition: ChargedDeviceTemplateDefinition?
    let batteryLevelReporting: BatteryLevelReporting
    let batteryBarsCount: Int
    let estimatedBatteryCapacityWh: Double?
    let apparentCapacityWh: Double?
    let configuredCompletionCurrentAmps: Double?
    let learnedCompletionCurrentAmps: Double?
    let minimumCurrentAmps: Double?
    let sourceObservedVoltageSelections: [Double]
    let sourceVoltageMaxCurrents: [Double: Double]
    let sourceIdleCurrentAmps: Double?
    let sourceMaximumPowerWatts: Double?
    let sourceEfficiencyFactor: Double?
    let notes: String?
    let createdAt: Date
    let updatedAt: Date
    let sessionsAsSubject: [ChargeSessionSummary]
    let sessionsAsSource: [ChargeSessionSummary]

    var fallbackIdentitySymbolName: String { "battery.100.bolt" }

    var identityIcon: ChargedDeviceTemplateIcon {
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
    }

    var identitySymbolName: String {
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
    }

    var identityTitle: String {
        templateDefinition?.name ?? "Powerbank"
    }

    /// Open session in which this powerbank participates as either subject or source.
    var openSession: ChargeSessionSummary? {
        sessionsAsSubject.first(where: \.isOpen)
            ?? sessionsAsSource.first(where: \.isOpen)
    }

    var totalDeliveredEnergyWh: Double {
        sessionsAsSource.reduce(0) { $0 + $1.measuredEnergyWh }
    }

    var totalReceivedEnergyWh: Double {
        sessionsAsSubject.reduce(0) { $0 + $1.measuredEnergyWh }
    }
}