//
//  DataStore.swift
//  USB Meter
//
//  Created by Bogdan Timofte on 03/03/2020.
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
//

import SwiftUI
import Combine
import CoreBluetooth
import CoreData
import UserNotifications

struct BatteryCheckpointPlausibilityWarning: Identifiable, Hashable {
    let title: String
    let message: String

    var id: String {
        "\(title)\n\(message)"
    }
}

final class AppData : ObservableObject {
    struct MeterSummary: Identifiable {
        let macAddress: String
        let displayName: String
        let modelSummary: String
        let advertisedName: String?
        let lastSeen: Date?
        let lastConnected: Date?
        let meter: Meter?

        var id: String {
            macAddress
        }
    }

    private var bluetoothManagerNotification: AnyCancellable?
    private var meterStoreObserver: AnyCancellable?
    private var meterStoreCloudObserver: AnyCancellable?
    private var chargeInsightsStoreObserver: AnyCancellable?
    private var chargeInsightsRemoteObserver: AnyCancellable?
    private var chargerStandbyPowerStoreObserver: AnyCancellable?
    private var pendingChargedDevicesReloadWorkItem: DispatchWorkItem?
    private let meterStore = MeterNameStore.shared
    private var chargeInsightsStore: ChargeInsightsStore?
    private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
    private let chargeNotificationCoordinator = ChargeNotificationCoordinator()

    init() {
        bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
            self?.scheduleObjectWillChange()
        }
        meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.refreshMeterMetadata()
            }
        meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.scheduleObjectWillChange()
            }
        chargerStandbyPowerStoreObserver = NotificationCenter.default.publisher(for: .chargerStandbyPowerStoreDidChange)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.reloadChargedDevices()
            }
    }

    let bluetoothManager = BluetoothManager()

    @Published var enableRecordFeature: Bool = true

    @Published var meters: [UUID:Meter] = [UUID:Meter]()
    @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
    @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:]

    var deviceSummaries: [ChargedDeviceSummary] {
        chargedDevices.filter { !$0.isCharger }
    }

    var chargerSummaries: [ChargedDeviceSummary] {
        chargedDevices.filter { $0.isCharger }
    }

    var cloudAvailability: MeterNameStore.CloudAvailability {
        meterStore.currentCloudAvailability
    }

    func activateChargeInsights(context: NSManagedObjectContext) {
        guard chargeInsightsStore == nil else {
            return
        }

        context.automaticallyMergesChangesFromParent = true
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        chargeInsightsStore = ChargeInsightsStore(context: context)

        chargeInsightsStoreObserver = NotificationCenter.default.publisher(
            for: .NSManagedObjectContextObjectsDidChange,
            object: context
        )
        .receive(on: DispatchQueue.main)
        .sink { [weak self] _ in
            self?.scheduleChargedDevicesReload()
        }

        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
            for: .NSPersistentStoreRemoteChange,
            object: nil
        )
        .receive(on: DispatchQueue.main)
        .sink { [weak self] _ in
            self?.scheduleChargedDevicesReload()
        }

        chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
        reloadChargedDevices()
    }

    func meterName(for macAddress: String) -> String? {
        meterStore.name(for: macAddress)
    }

    func setMeterName(_ name: String, for macAddress: String) {
        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
    }

    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
    }

    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
    }

    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
        meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
    }

    func noteMeterSeen(at date: Date, macAddress: String) {
        meterStore.noteLastSeen(date, for: macAddress)
    }

    func noteMeterConnected(at date: Date, macAddress: String) {
        meterStore.noteLastConnected(date, for: macAddress)
    }

    func lastSeen(for macAddress: String) -> Date? {
        meterStore.lastSeen(for: macAddress)
    }

    func lastConnected(for macAddress: String) -> Date? {
        meterStore.lastConnected(for: macAddress)
    }

    func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
        chargedDevices.first(where: { $0.id == id })
    }

    func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
        for chargedDevice in chargedDevices {
            if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
                return session
            }
        }
        return nil
    }

    func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
        return chargedDevices.filter { chargedDevice in
            guard chargedDevice.isCharger == false else {
                return false
            }
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
        }
    }

    func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
        return chargedDevices.filter { chargedDevice in
            guard chargedDevice.isCharger else {
                return false
            }
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
        }
    }

    func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()

        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
           let liveDevice = chargedDevices.first(where: {
               $0.id == activeSession.chargedDeviceID && $0.isCharger == false
           }) {
            return liveDevice
        }

        return chargedDevices.first(where: {
            $0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC
        })
    }

    func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()

        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
           let chargerID = activeSession.chargerID,
           let liveCharger = chargedDevices.first(where: {
               $0.id == chargerID && $0.isCharger
           }) {
            return liveCharger
        }

        return chargedDevices.first(where: {
            $0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC
        })
    }

    func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
        if let cachedSummary = cachedActiveChargeSessionSummary(for: meterMACAddress) {
            return cachedSummary
        }
        return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
    }

    func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
        activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)]
    }

    @discardableResult
    func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool {
        guard chargedDeviceSummary(id: chargerID)?.isCharger == true else {
            return false
        }

        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
        if let existingSession = activeChargerStandbySessions[normalizedMAC] {
            return existingSession.chargerID == chargerID
        }

        let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter)
        session.onChange = { [weak self] in
            self?.scheduleObjectWillChange()
        }
        session.onStabilized = { [weak self, weak session] in
            guard let self, let session else { return }
            self.notifyChargerStandbyMeasurementReady(for: session)
        }

        activeChargerStandbySessions[normalizedMAC] = session
        session.start()

        // Starting a standby run on an available meter should also initiate the BLE link.
        if meter.operationalState == .peripheralNotConnected {
            meter.connect()
        }

        scheduleObjectWillChange()
        return true
    }

    @discardableResult
    func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool {
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
        guard let session = activeChargerStandbySessions[normalizedMAC] else {
            return false
        }

        session.stop()

        guard save else {
            activeChargerStandbySessions[normalizedMAC] = nil
            scheduleObjectWillChange()
            return true
        }

        guard let summary = session.makeSummary() else {
            scheduleObjectWillChange()
            return false
        }

        let didSave = chargerStandbyPowerStore.save(summary)
        if didSave {
            activeChargerStandbySessions[normalizedMAC] = nil
            reloadChargedDevices()
        } else {
            scheduleObjectWillChange()
        }

        return didSave
    }

    @discardableResult
    func deleteChargerStandbyMeasurement(id: UUID, chargerID: UUID) -> Bool {
        let didDelete = chargerStandbyPowerStore.removeMeasurement(id: id, chargerID: chargerID)
        if didDelete {
            reloadChargedDevices()
        } else {
            scheduleObjectWillChange()
        }
        return didDelete
    }

    @discardableResult
    func createDevice(
        name: String,
        deviceClass: ChargedDeviceClass,
        chargingStateAvailability: ChargingStateAvailability,
        supportsWiredCharging: Bool,
        supportsWirelessCharging: Bool,
        wirelessChargingProfile: WirelessChargingProfile,
        configuredCompletionCurrents: [ChargeSessionKind: Double],
        notes: String?,
        meterMACAddress: String?
    ) -> Bool {
        let didSave = chargeInsightsStore?.createDevice(
            name: name,
            deviceClass: deviceClass,
            chargingStateAvailability: chargingStateAvailability,
            supportsWiredCharging: supportsWiredCharging,
            supportsWirelessCharging: supportsWirelessCharging,
            wirelessChargingProfile: wirelessChargingProfile,
            configuredCompletionCurrents: configuredCompletionCurrents,
            notes: notes,
            assignTo: meterMACAddress
        ) ?? false

        if didSave {
            reloadChargedDevices()
        }

        return didSave
    }

    @discardableResult
    func createCharger(
        name: String,
        notes: String?,
        meterMACAddress: String?
    ) -> Bool {
        let didSave = chargeInsightsStore?.createCharger(
            name: name,
            notes: notes,
            assignTo: meterMACAddress
        ) ?? false

        if didSave {
            reloadChargedDevices()
        }

        return didSave
    }

    @discardableResult
    func updateDevice(
        id: UUID,
        name: String,
        deviceClass: ChargedDeviceClass,
        chargingStateAvailability: ChargingStateAvailability,
        supportsWiredCharging: Bool,
        supportsWirelessCharging: Bool,
        wirelessChargingProfile: WirelessChargingProfile,
        configuredCompletionCurrents: [ChargeSessionKind: Double],
        notes: String?
    ) -> Bool {
        let didSave = chargeInsightsStore?.updateDevice(
            id: id,
            name: name,
            deviceClass: deviceClass,
            chargingStateAvailability: chargingStateAvailability,
            supportsWiredCharging: supportsWiredCharging,
            supportsWirelessCharging: supportsWirelessCharging,
            wirelessChargingProfile: wirelessChargingProfile,
            configuredCompletionCurrents: configuredCompletionCurrents,
            notes: notes
        ) ?? false

        if didSave {
            reloadChargedDevices()
        }

        return didSave
    }

    @discardableResult
    func updateCharger(
        id: UUID,
        name: String,
        notes: String?
    ) -> Bool {
        let didSave = chargeInsightsStore?.updateCharger(
            id: id,
            name: name,
            notes: notes
        ) ?? false

        if didSave {
            reloadChargedDevices()
        }

        return didSave
    }

    @discardableResult
    func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
        let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false
        if didSave {
            reloadChargedDevices()
        }
        return didSave
    }

    @discardableResult
    func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
        let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false
        if didSave {
            reloadChargedDevices()
        }
        return didSave
    }

    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
            return
        }
        guard activeSession.status == .active else {
            return
        }
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
    }

    @discardableResult
    func startChargeSession(
        for meter: Meter,
        chargedDeviceID: UUID,
        chargerID: UUID?,
        chargingTransportMode: ChargingTransportMode,
        chargingStateMode: ChargingStateMode,
        autoStopEnabled: Bool,
        initialBatteryPercent: Double?,
        startsFromFlatBattery: Bool
    ) -> Bool {
        meter.resetMeterCountersForNewSession()

        guard let snapshot = meter.chargingMonitorSnapshot else {
            return false
        }

        let didSave = chargeInsightsStore?.startSession(
            for: snapshot,
            chargedDeviceID: chargedDeviceID,
            chargerID: chargerID,
            chargingTransportMode: chargingTransportMode,
            chargingStateMode: chargingStateMode,
            autoStopEnabled: autoStopEnabled,
            initialBatteryPercent: initialBatteryPercent,
            startsFromFlatBattery: startsFromFlatBattery
        ) ?? false
        if didSave {
            reloadChargedDevices()
            meter.resetChargeRecordGraph()
            if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description),
               meter.supportsRecordingThreshold,
               activeSession.stopThresholdAmps > 0 {
                meter.recordingTreshold = activeSession.stopThresholdAmps
            }
            restoreChargeMonitoringStateIfNeeded(for: meter)
        }
        return didSave
    }

    @discardableResult
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
        if didSave {
            reloadChargedDevices()
        }
        return didSave
    }

    @discardableResult
    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
        let snapshot = meter?.chargingMonitorSnapshot
        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
        if didSave {
            reloadChargedDevices()
        }
        return didSave
    }

    @discardableResult
    func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double) -> Bool {
        let didSave = chargeInsightsStore?.stopSession(
            id: sessionID,
            finalBatteryPercent: finalBatteryPercent
        ) ?? false
        if didSave {
            reloadChargedDevices()
        }
        return didSave
    }

    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
            return
        }

        if chargeInsightsStore?.observe(snapshot: snapshot) == true {
            reloadChargedDevices()
        }
    }

    @discardableResult
    func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
        observeChargeSnapshot(from: meter)

        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }

        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
            percent: percent,
            for: meter.btSerial.macAddress.description,
            measuredEnergyWh: checkpointEnergyWh,
            measuredChargeAh: checkpointChargeAh
        ) ?? false

        if didSave {
            reloadChargedDevices()
        }

        return didSave
    }

    @discardableResult
    func addBatteryCheckpoint(percent: Double, for sessionID: UUID) -> Bool {
        guard canAddBatteryCheckpoint(to: sessionID) else {
            return false
        }

        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
            percent: percent,
            for: sessionID
        ) ?? false

        if didSave {
            reloadChargedDevices()
        }

        return didSave
    }

    @discardableResult
    func addBatteryCheckpoint(
        percent: Double,
        for sessionID: UUID,
        measuredEnergyWh: Double?,
        measuredChargeAh: Double?
    ) -> Bool {
        guard canAddBatteryCheckpoint(to: sessionID) else {
            return false
        }

        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
            percent: percent,
            for: sessionID,
            measuredEnergyWh: measuredEnergyWh,
            measuredChargeAh: measuredChargeAh
        ) ?? false

        if didSave {
            reloadChargedDevices()
        }

        return didSave
    }

    func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool {
        guard let session = chargeSessionSummary(id: sessionID),
              session.status.isOpen,
              let meterMACAddress = session.meterMACAddress else {
            return false
        }

        return meter(for: meterMACAddress) != nil
    }

    func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? {
        guard let session = chargeSessionSummary(id: sessionID) else {
            return "Battery checkpoints are available only while the charge session is still active."
        }

        guard session.status.isOpen else {
            return "Battery checkpoints are available only while the charge session is still active."
        }

        guard let meterMACAddress = session.meterMACAddress,
              meter(for: meterMACAddress) != nil else {
            return "Add battery checkpoints only on the device that is actively monitoring this charging session. Devices following the session through iCloud may not have data that is fresh or precise enough."
        }

        return nil
    }

    func batteryCheckpointPlausibilityWarning(
        percent: Double,
        for sessionID: UUID,
        effectiveEnergyWhOverride: Double? = nil
    ) -> BatteryCheckpointPlausibilityWarning? {
        guard let session = chargeSessionSummary(id: sessionID) else {
            return nil
        }
        return batteryCheckpointPlausibilityWarning(
            percent: percent,
            for: session,
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
        )
    }

    @discardableResult
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
            id: checkpointID,
            from: sessionID
        ) ?? false

        if didDelete {
            scheduleChargedDevicesReload(delay: 0)
        }

        return didDelete
    }

    @discardableResult
    func flushChargeInsights() -> Bool {
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
        reloadChargedDevices()
        return didSave
    }

    @discardableResult
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
            return false
        }
        return setTargetBatteryPercent(percent, for: activeSession.id)
    }

    @discardableResult
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
        if percent != nil {
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
        }

        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
        if didSave {
            reloadChargedDevices()
        }
        return didSave
    }

    @discardableResult
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
        if didSave {
            reloadChargedDevices()
        }
        return didSave
    }

    @discardableResult
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
        if didSave {
            reloadChargedDevices()
        }
        return didSave
    }

    @discardableResult
    func deleteChargeSession(sessionID: UUID) -> Bool {
        let deletedSession = chargedDevices
            .flatMap(\.sessions)
            .first(where: { $0.id == sessionID })

        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
        guard didDelete else {
            return false
        }

        if deletedSession?.status.isOpen == true,
           let meterMACAddress = deletedSession?.meterMACAddress,
           let liveMeter = meter(for: meterMACAddress) {
            liveMeter.resetChargeRecord()
        }

        reloadChargedDevices()
        return true
    }

    @discardableResult
    func deleteChargedDevice(id: UUID) -> Bool {
        let deletedDevice = chargedDeviceSummary(id: id)
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
        guard didDelete else {
            return false
        }

        if deletedDevice?.isCharger == true {
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
                session.stop()
                activeChargerStandbySessions[meterMACAddress] = nil
            }
        }

        if deletedDevice?.isCharger == false,
           deletedDevice?.activeSession?.status.isOpen == true,
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
           let liveMeter = meter(for: meterMACAddress) {
            liveMeter.resetChargeRecord()
        }

        reloadChargedDevices()
        return true
    }

    @discardableResult
    func createKnownMeter(
        macAddress: String,
        customName: String?,
        modelName: String,
        advertisedName: String?
    ) -> Bool {
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
        guard Self.isValidMACAddress(normalizedMAC) else {
            return false
        }

        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
            setMeterName(customName, for: normalizedMAC)
        }
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
        return true
    }

    @discardableResult
    func deleteMeter(macAddress: String) -> Bool {
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
        guard Self.isValidMACAddress(normalizedMAC) else {
            return false
        }

        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
            meter.disconnect()
        }
        meters = meters.filter { element in
            element.value.btSerial.macAddress.description != normalizedMAC
        }

        let didDelete = meterStore.remove(macAddress: normalizedMAC)
        if didDelete {
            scheduleObjectWillChange()
        }
        return didDelete
    }

    var meterSummaries: [MeterSummary] {
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)

        return macAddresses.map { macAddress in
            let liveMeter = liveMetersByMAC[macAddress]
            let record = recordsByMAC[macAddress]

            return MeterSummary(
                macAddress: macAddress,
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
                meter: liveMeter
            )
        }
        .sorted { lhs, rhs in
            if lhs.meter != nil && rhs.meter == nil {
                return true
            }
            if lhs.meter == nil && rhs.meter != nil {
                return false
            }
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
            if byName != .orderedSame {
                return byName == .orderedAscending
            }
            return lhs.macAddress < rhs.macAddress
        }
    }

    private func scheduleObjectWillChange() {
        DispatchQueue.main.async { [weak self] in
            self?.objectWillChange.send()
        }
    }

    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
        pendingChargedDevicesReloadWorkItem?.cancel()

        let workItem = DispatchWorkItem { [weak self] in
            self?.reloadChargedDevices()
        }
        pendingChargedDevicesReloadWorkItem = workItem
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
    }

    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
        guard !normalizedMAC.isEmpty else {
            return nil
        }

        return chargedDevices
            .lazy
            .compactMap(\.activeSession)
            .first(where: { $0.status == .active && $0.meterMACAddress == normalizedMAC })
    }

    private func reloadChargedDevices() {
        pendingChargedDevicesReloadWorkItem?.cancel()
        pendingChargedDevicesReloadWorkItem = nil

        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
        chargedDevices = (chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
            chargedDevice.withStandbyPowerMeasurements(
                standbyMeasurementsByChargerID[chargedDevice.id] ?? []
            )
        }
        chargeNotificationCoordinator.process(chargedDevices: deviceSummaries)
        for meter in meters.values {
            restoreChargeMonitoringStateIfNeeded(for: meter)
        }
    }

    private func meter(for meterMACAddress: String) -> Meter? {
        meters.values.first { meter in
            meter.btSerial.macAddress.description == meterMACAddress
        }
    }

    private func refreshMeterMetadata() {
        DispatchQueue.main.async { [weak self] in
            guard let self else { return }
            var didUpdateAnyMeter = false
            for meter in self.meters.values {
                let mac = meter.btSerial.macAddress.description
                let displayName = self.meterName(for: mac) ?? mac
                if meter.name != displayName {
                    meter.updateNameFromStore(displayName)
                    didUpdateAnyMeter = true
                }

                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
                meter.reloadTemperatureUnitPreference()
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
                    didUpdateAnyMeter = true
                }
            }

            if didUpdateAnyMeter {
                self.scheduleObjectWillChange()
            }
        }
    }

    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
        guard let charger = chargedDeviceSummary(id: session.chargerID),
              let statistics = session.statistics else {
            return
        }

        let content = UNMutableNotificationContent()
        content.title = "Standby baseline stabilised"
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
        content.sound = .default
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"

        let request = UNNotificationRequest(
            identifier: "charger-standby-\(session.id.uuidString)",
            content: content,
            trigger: nil
        )
        UNUserNotificationCenter.current().add(request)
        scheduleObjectWillChange()
    }

    private func batteryCheckpointPlausibilityWarning(
        percent: Double,
        for session: ChargeSessionSummary,
        effectiveEnergyWhOverride: Double? = nil
    ) -> BatteryCheckpointPlausibilityWarning? {
        guard percent.isFinite, percent >= 0, percent <= 100 else {
            return nil
        }

        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
            if lhs.timestamp != rhs.timestamp {
                return lhs.timestamp < rhs.timestamp
            }
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
            }
            return lhs.id.uuidString < rhs.id.uuidString
        }

        if let lastCheckpoint = sortedCheckpoints.last,
           percent < lastCheckpoint.batteryPercent - 1.5 {
            return BatteryCheckpointPlausibilityWarning(
                title: "Checkpoint Goes Backwards",
                message: "The latest checkpoint is \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.timestamp.format()). A new value of \(percent.format(decimalDigits: 0))% is unexpectedly lower while the session is still charging."
            )
        }

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

        if let lastCheckpoint = sortedCheckpoints.last,
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
            let estimatedCapacityWh = session.capacityEstimateWh
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
                ?? chargedDevice.estimatedBatteryCapacityWh

            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
                let expectedPercent = min(
                    100,
                    max(
                        lastCheckpoint.batteryPercent,
                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
                    )
                )
                let predictionGap = percent - expectedPercent
                guard abs(predictionGap) >= 4 else {
                    return nil
                }

                let direction = predictionGap > 0 ? "above" : "below"
                let gapText = abs(predictionGap).format(decimalDigits: 0)
                let expectedText = expectedPercent.format(decimalDigits: 0)

                return BatteryCheckpointPlausibilityWarning(
                    title: "Checkpoint Looks Implausible",
                    message: "The last checkpoint stored \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh. The current counted energy is \(effectiveEnergyWh.format(decimalDigits: 2)) Wh, which supports about \(expectedText)% based on \(estimatedCapacityWh.format(decimalDigits: 2)) Wh capacity. The entered value is about \(gapText) percentage points \(direction) that."
                )
            }
        }

        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
              let prediction = chargedDevice.batteryLevelPrediction(
                for: session,
                effectiveEnergyWhOverride: effectiveEnergyWh
              )
        else {
            return nil
        }

        let predictionGap = percent - prediction.predictedPercent
        guard abs(predictionGap) >= 4 else {
            return nil
        }

        let direction = predictionGap > 0 ? "above" : "below"
        let gapText = abs(predictionGap).format(decimalDigits: 0)
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)

        if let lastCheckpoint = sortedCheckpoints.last {
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
            return BatteryCheckpointPlausibilityWarning(
                title: "Checkpoint Looks Implausible",
                message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Since the last checkpoint at \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))%, only \(energyDeltaWh.format(decimalDigits: 2)) Wh were added."
            )
        }

        return BatteryCheckpointPlausibilityWarning(
            title: "Checkpoint Looks Implausible",
            message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Save it only if the device gauge really jumped that much."
        )
    }

    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
        guard session.status.isOpen else {
            return storedEnergyWh
        }

        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
            return storedEnergyWh
        }

        if let baselineEnergyWh = session.meterEnergyBaselineWh {
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
        }

        return storedEnergyWh
    }

    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
        let storedChargeAh = session.measuredChargeAh
        guard session.status.isOpen else {
            return storedChargeAh
        }

        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
            return storedChargeAh
        }

        if let baselineChargeAh = session.meterChargeBaselineAh {
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
        }

        return storedChargeAh
    }
}

extension AppData.MeterSummary {
    var tint: Color {
        switch modelSummary {
        case "UM25C":
            return .blue
        case "UM34C":
            return .yellow
        case "TC66C":
            return Model.TC66C.color
        default:
            return .secondary
        }
    }
}

extension AppData {
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
            return liveName
        }
        if let customName = record?.customName {
            return customName
        }
        if let advertisedName = record?.advertisedName {
            return advertisedName
        }
        if let recordModel = record?.modelName {
            return recordModel
        }
        if let liveModel = liveMeter?.deviceModelSummary {
            return liveModel
        }
        return "Meter"
    }

    static func normalizedMACAddress(_ macAddress: String) -> String {
        macAddress
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .uppercased()
    }

    static func isValidMACAddress(_ macAddress: String) -> Bool {
        macAddress.range(
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
            options: .regularExpression
        ) != nil
    }
}

private final class ChargeNotificationCoordinator {
    private struct Payload {
        let id: String
        let title: String
        let body: String
        let threadIdentifier: String
    }

    private let notificationCenter = UNUserNotificationCenter.current()
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
    private var inFlightEventIDs: Set<String> = []

    func ensureAuthorizationIfNeeded() {
        notificationCenter.getNotificationSettings { [weak self] settings in
            guard settings.authorizationStatus == .notDetermined else {
                return
            }

            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
                if let error {
                    track("Notification authorization request failed: \(error.localizedDescription)")
                }
            }
        }
    }

    func process(chargedDevices: [ChargedDeviceSummary]) {
        let now = Date()
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
            payloads(for: chargedDevice, now: now)
        }

        for payload in pendingPayloads {
            scheduleIfNeeded(payload)
        }
    }

    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
        chargedDevice.sessions.compactMap { session in
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
               let targetBatteryPercent = session.targetBatteryPercent {
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
                    ?? session.endBatteryPercent
                    ?? targetBatteryPercent

                return Payload(
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
                    title: "Battery target reached",
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
                    threadIdentifier: session.id.uuidString
                )
            }

            if session.requiresCompletionConfirmation,
               let requestedAt = session.completionConfirmationRequestedAt,
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
                let estimatedPercent = session.completionContradictionPercent
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
                let detail = estimatedPercent.map {
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
                } ?? ""

                return Payload(
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
                    title: "Confirm charge completion",
                    body: bodyPrefix + detail,
                    threadIdentifier: session.id.uuidString
                )
            }

            return nil
        }
    }

    private func scheduleIfNeeded(_ payload: Payload) {
        guard deliveredEventIDs().contains(payload.id) == false else {
            return
        }

        guard inFlightEventIDs.contains(payload.id) == false else {
            return
        }

        inFlightEventIDs.insert(payload.id)

        notificationCenter.getNotificationSettings { [weak self] settings in
            guard let self else { return }
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
                DispatchQueue.main.async {
                    self.inFlightEventIDs.remove(payload.id)
                }
                return
            }

            let content = UNMutableNotificationContent()
            content.title = payload.title
            content.body = payload.body
            content.sound = .default
            content.threadIdentifier = payload.threadIdentifier

            let request = UNNotificationRequest(
                identifier: payload.id,
                content: content,
                trigger: nil
            )

            self.notificationCenter.add(request) { error in
                DispatchQueue.main.async {
                    self.inFlightEventIDs.remove(payload.id)
                    if let error {
                        track("Failed scheduling local notification: \(error.localizedDescription)")
                        return
                    }
                    self.storeDeliveredEventID(payload.id)
                }
            }
        }
    }

    private func deliveredEventIDs() -> Set<String> {
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
        return Set(values)
    }

    private func storeDeliveredEventID(_ id: String) {
        var values = deliveredEventIDs()
        values.insert(id)
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
    }
}
