import Foundation
import IndieSync
import os
import WatchConnectivity

/// Watch side of the recording hand-off. The watch has no iCloud; a finished
/// recording becomes durable on the phone, which ingests and transcribes it.
///
/// Each queued recording is a `<ULID>.m4a` plus a `<ULID>.meta.plist` sidecar in
/// `Documents/Recordings/`. `transferFile` hands the audio to WatchConnectivity
/// with an `AudioNotePayload` as metadata. Local files are deleted **only** when
/// the phone sends an `IngestAck` — the end-to-end confirmation that the
/// recording is durably in the iCloud container (or permanently vetoed there).
/// WC-level `didFinish` success is not enough: the phone can still fail after
/// delivery (staging, iCloud write), and this local copy is the only recovery.
/// Anything never acked is re-enqueued by `reconcileQueue()` from the persisted
/// sidecar on the next activation/foreground; the phone dedupes and re-acks.
/// `pendingCount` (files still on disk) drives the "N waiting for iPhone" footer.
///
/// Strict concurrency: the delegate callbacks are nonisolated and run on
/// WatchConnectivity's background queue — they extract Sendable values (a file
/// name string) before hopping to the main actor. `WCSessionFile*` are not
/// Sendable and never cross the hop.
@Observable
@MainActor
final class WatchTransferService: NSObject {
    private nonisolated static let log = Logger(
        subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "watch-transfer")

    /// Recordings still on disk (queued or in-flight) — the count the phone has
    /// not yet confirmed receiving.
    private(set) var pendingCount = 0

    private var session: WCSession?

    /// Recordings WC reported delivered this session but the phone hasn't yet
    /// acked as ingested. Skipped by `reconcileQueue` so a foreground pass right
    /// after delivery doesn't re-send a whole audio file the phone is busy
    /// ingesting. In-memory only: after a relaunch these fall back into the
    /// reconcile pool, and a redundant redelivery is harmless (phone dedupes).
    private var deliveredAwaitingAck: Set<String> = []

    func activate() {
        guard WCSession.isSupported() else { return }
        let session = WCSession.default
        session.delegate = self
        session.activate()
        self.session = session
        refreshPendingCount()
    }

    /// Queue a finished recording for delivery. Writes the sidecar first (so a
    /// relaunch before delivery can re-enqueue it), then hands the file to
    /// `transferFile`.
    func enqueue(_ recording: WatchAudioRecorder.Recording) {
        let tz = TimeZone.current.identifier
        writeSidecar(base: recording.id.stringValue,
                     recordedAt: recording.recordedAt, duration: recording.duration, timeZoneID: tz)
        startTransfer(id: recording.id.stringValue, url: recording.url,
                      recordedAt: recording.recordedAt, duration: recording.duration, timeZoneID: tz)
        refreshPendingCount()
    }

    /// Re-enqueue any recording still on disk that the session isn't already
    /// transferring — a file whose delivery we never confirmed. Runs when the
    /// session activates (relaunch) and when the app returns to the foreground.
    func reconcileQueue() {
        guard let session else { refreshPendingCount(); return }
        let inFlight = Set(session.outstandingFileTransfers.map { $0.file.fileURL.lastPathComponent })
        let fm = FileManager.default
        let dir = WatchAudioRecorder.recordingsDirectory
        let entries = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
        for m4a in entries where m4a.pathExtension == "m4a" {
            if inFlight.contains(m4a.lastPathComponent) { continue }
            if deliveredAwaitingAck.contains(m4a.lastPathComponent) { continue }
            let base = m4a.deletingPathExtension().lastPathComponent
            guard ULID(string: base) != nil else { continue }
            let meta = readSidecar(base: base)
            startTransfer(id: base, url: m4a,
                          recordedAt: meta?.recordedAt ?? Date(),
                          duration: meta?.duration ?? 0,
                          timeZoneID: meta?.timeZoneID)
        }
        // Sweep sidecars whose audio file is gone (e.g. a simulator "recording"
        // that never produced a file) so they don't linger forever.
        let audioBases = Set(entries.filter { $0.pathExtension == "m4a" }
            .map { $0.deletingPathExtension().lastPathComponent })
        for sidecar in entries where sidecar.lastPathComponent.hasSuffix(".meta.plist") {
            let base = sidecar.lastPathComponent.replacingOccurrences(of: ".meta.plist", with: "")
            if !audioBases.contains(base) {
                try? fm.removeItem(at: sidecar)
            }
        }
        refreshPendingCount()
    }

    // MARK: - Transfer

    private func startTransfer(id: String, url: URL, recordedAt: Date, duration: TimeInterval, timeZoneID: String?) {
        guard let session, session.activationState == .activated,
              FileManager.default.fileExists(atPath: url.path) else { return }
        let payload = AudioNotePayload(
            id: id, recordedAt: recordedAt, duration: duration, timeZoneID: timeZoneID)
        session.transferFile(url, metadata: payload.metadata())
    }

    /// Delete an **acked** recording and its sidecar — called only when the
    /// phone's `IngestAck` arrives, never on mere WC delivery success.
    private func completeTransfer(fileName: String) {
        let dir = WatchAudioRecorder.recordingsDirectory
        let base = (fileName as NSString).deletingPathExtension
        try? FileManager.default.removeItem(at: dir.appending(path: fileName))
        try? FileManager.default.removeItem(at: sidecarURL(base: base))
        deliveredAwaitingAck.remove(fileName)
        refreshPendingCount()
    }

    /// WC delivered the file to the phone; park it until the ingest ack.
    private func markDelivered(fileName: String) {
        deliveredAwaitingAck.insert(fileName)
        refreshPendingCount()
    }

    // MARK: - Pending count

    private func refreshPendingCount() {
        let dir = WatchAudioRecorder.recordingsDirectory
        let entries = (try? FileManager.default.contentsOfDirectory(
            at: dir, includingPropertiesForKeys: nil)) ?? []
        pendingCount = entries.filter { $0.pathExtension == "m4a" }.count
    }

    // MARK: - Sidecar (persisted metadata for re-enqueue after relaunch)

    private func sidecarURL(base: String) -> URL {
        WatchAudioRecorder.recordingsDirectory.appending(path: "\(base).meta.plist")
    }

    private func writeSidecar(base: String, recordedAt: Date, duration: TimeInterval, timeZoneID: String?) {
        var dict: [String: Any] = ["recordedAt": recordedAt, "duration": duration]
        if let timeZoneID { dict["timeZoneID"] = timeZoneID }
        if let data = try? PropertyListSerialization.data(fromPropertyList: dict, format: .binary, options: 0) {
            try? data.write(to: sidecarURL(base: base))
        }
    }

    private func readSidecar(base: String) -> (recordedAt: Date, duration: TimeInterval, timeZoneID: String?)? {
        guard let data = try? Data(contentsOf: sidecarURL(base: base)),
              let dict = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any],
              let recordedAt = dict["recordedAt"] as? Date,
              let duration = dict["duration"] as? Double else { return nil }
        return (recordedAt, duration, dict["timeZoneID"] as? String)
    }
}

// MARK: - WCSessionDelegate

extension WatchTransferService: WCSessionDelegate {
    nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
        if let error { Self.log.error("watch WC activation failed: \(error, privacy: .public)") }
        Task { @MainActor in self.reconcileQueue() }
    }

    nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
        // A queued file whose transfer never registered (session wasn't yet
        // activated at enqueue) gets re-driven once the phone is reachable again.
        if session.isReachable {
            Task { @MainActor in self.reconcileQueue() }
        }
    }

    nonisolated func session(_ session: WCSession, didFinish fileTransfer: WCSessionFileTransfer, error: (any Error)?) {
        // Extract the Sendable file name before hopping — WCSessionFileTransfer /
        // WCSessionFile are not Sendable.
        let fileName = fileTransfer.file.fileURL.lastPathComponent
        if let error {
            Self.log.warning("file transfer failed (\(fileName, privacy: .public)): \(error, privacy: .public)")
            Task { @MainActor in self.refreshPendingCount() }
            return
        }
        // Delivered ≠ ingested: keep the local copy until the phone's ack.
        Task { @MainActor in self.markDelivered(fileName: fileName) }
    }

    nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
        // The phone's ingest ack — the recording is durable in iCloud (or
        // permanently vetoed); the local copy has done its job.
        guard let ack = IngestAck(userInfo: userInfo) else { return }
        Task { @MainActor in self.completeTransfer(fileName: "\(ack.id).m4a") }
    }
}
