#if os(iOS)
import Foundation
import os
import WatchConnectivity

/// Phone side of the watch recording hand-off. Receives a `WCSessionFile` the
/// watch sent via `transferFile`, stages it into the local `WatchInbox` (with the
/// `<ULID>.m4a` + `<ULID>.meta.plist` layout `SyncEngine.ingestStagedWatchRecordings`
/// expects), then asks the sync engine to drain the inbox.
///
/// The move happens **synchronously in the nonisolated delegate callback**: the
/// system deletes the received inbox file the instant `didReceive` returns, so
/// deferring the move to a `MainActor` hop would race the bytes away. Only after
/// staging do we hop to the main actor to trigger ingest.
///
/// Activated from the `UIApplicationDelegate` (not a SwiftUI `.task`) so a
/// background launch the system makes purely to deliver a transfer still stages
/// the file. `syncEngine` is wired in later, once `AppServices` exists; when it's
/// nil (a background launch with no UI yet) the staged file is drained on the
/// next foreground connect instead.
@MainActor
final class PhoneWatchReceiver: NSObject {
    private nonisolated static let log = Logger(
        subsystem: "dev.rzen.indie.Notes", category: "phone-watch-receiver")

    /// The live sync engine, set once `AppServices` is built. Weak: `AppServices`
    /// owns it for the app's lifetime; the receiver only borrows it to trigger a
    /// drain when one is available.
    weak var syncEngine: SyncEngine?

    private var session: WCSession?

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

    /// Tell the watch a recording reached a terminal state here, so it can
    /// delete its local copy. `transferUserInfo` queues durably — the ack
    /// arrives even if the watch app is closed right now. Wired to
    /// `SyncEngine.onWatchRecordingHandled` by `NotesApp`.
    func acknowledgeIngest(id: String) {
        guard let session, session.activationState == .activated else { return }
        session.transferUserInfo(IngestAck(id: id).userInfo())
    }
}

// MARK: - WCSessionDelegate

extension PhoneWatchReceiver: WCSessionDelegate {
    nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
        if let error { Self.log.error("phone WC activation failed: \(error, privacy: .public)") }
    }

    nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}

    nonisolated func sessionDidDeactivate(_ session: WCSession) {
        // Reactivate for a switched watch.
        session.activate()
    }

    nonisolated func session(_ session: WCSession, didReceive file: WCSessionFile) {
        // Synchronous move — the inbox file is deleted the moment this returns.
        Self.stage(file: file)
        // Drain if the engine is live; otherwise the next foreground connect drains.
        Task { @MainActor in await self.syncEngine?.ingestStagedWatchRecordings() }
    }

    /// Move the received audio into `WatchInbox` and write the metadata sidecar
    /// `SyncEngine`'s drain reads. On a decodable payload this stages a full
    /// `<ULID>.m4a` + `<ULID>.meta.plist` pair. On an unknown/newer payload
    /// version (the forward gate) the bytes are still quarantined in the inbox —
    /// but without a sidecar, so the drain skips them and a future build can pick
    /// them up.
    nonisolated private static func stage(file: WCSessionFile) {
        let fm = FileManager.default
        let inbox = SyncEngine.watchInboxURL
        try? fm.createDirectory(at: inbox, withIntermediateDirectories: true)

        let metadata = file.metadata ?? [:]

        guard let payload = AudioNotePayload(metadata: metadata) else {
            // Forward gate: keep the bytes but don't make them ingestible.
            let base = (metadata["id"] as? String) ?? UUID().uuidString
            let dest = inbox.appending(path: "\(base).m4a")
            try? fm.removeItem(at: dest)
            do {
                try fm.moveItem(at: file.fileURL, to: dest)
                log.error("staged watch recording \(base, privacy: .public) with unknown payload version — skipping ingest")
            } catch {
                log.error("failed to stage unknown-version recording: \(error, privacy: .public)")
            }
            return
        }

        let base = payload.id
        let audioDest = inbox.appending(path: "\(base).m4a")
        let metaDest = inbox.appending(path: "\(base).meta.plist")
        // Idempotent for a redelivery of the same recording.
        try? fm.removeItem(at: audioDest)
        do {
            try fm.moveItem(at: file.fileURL, to: audioDest)
        } catch {
            // Bytes lost on this delivery — safe because the watch keeps its
            // copy until the ingest ack, which this recording never gets; the
            // next watch reconcile redelivers it.
            log.error("failed to stage recording \(base, privacy: .public): \(error, privacy: .public)")
            return
        }

        var meta: [String: Any] = [
            "id": payload.id,
            "recordedAt": payload.recordedAt,
            "duration": payload.duration,
        ]
        if let tz = payload.timeZoneID { meta["timeZoneID"] = tz }
        do {
            let data = try PropertyListSerialization.data(fromPropertyList: meta, format: .binary, options: 0)
            try data.write(to: metaDest)
        } catch {
            // A sidecar-less m4a would sit in the inbox forever (the drain
            // can't ingest it). Remove the audio too and let the un-acked
            // watch copy drive a clean redelivery instead.
            try? fm.removeItem(at: audioDest)
            log.error("failed to write sidecar for \(base, privacy: .public), unstaging: \(error, privacy: .public)")
            return
        }
        log.info("staged watch recording \(base, privacy: .public) for ingest")
    }
}
#endif
