import AVFoundation
import Foundation
import IndieSync
import OSLog
import WatchKit

/// Records a single voice note on the watch into a persisted
/// `Documents/Recordings/<ULID>.m4a` (AAC mono 44.1 kHz ~48 kbps). The ULID is
/// minted at record **start**, so the note's identity — and its UTC time bucket
/// once ingested on the phone — reflects when it was recorded, not when it later
/// reaches the phone.
///
/// The file lands in the app's Documents (not temp) so a queued recording
/// survives suspension until `WatchTransferService` confirms the phone received
/// it. `AVAudioRecorder` is not Sendable, so the recorder stays confined to the
/// main actor for its whole life.
@Observable
@MainActor
final class WatchAudioRecorder {
    /// One finished recording: the file on disk, its length, its ULID, and the
    /// instant it started. The caller (`WatchTransferService`) owns delivery.
    struct Recording: Equatable, Sendable {
        let id: ULID
        let url: URL
        let duration: TimeInterval
        let recordedAt: Date
    }

    private(set) var isRecording = false
    /// Seconds recorded so far, refreshed by the tick loop while recording.
    private(set) var elapsed: TimeInterval = 0

    /// Fired when a recording ends without the user tapping Stop — the 10-minute
    /// safety cap fires. The finished file is handed up to be queued rather than
    /// lost. (Scene-deactivation stop-and-queue is driven by `WatchAppServices`,
    /// which calls `stopRecording()` directly.)
    var onInvoluntaryFinish: ((Recording) -> Void)?

    /// Fired when a take captures no audio: an encode error mid-recording, or a
    /// stop that finds a header-only file (`record()` can report success on
    /// watchOS while the capture silently fails — the failure only surfaces
    /// through the delegate or in the dead file). The message is shown in the
    /// watch UI; there's no console on a real watch.
    var onCaptureFailure: ((String) -> Void)?

    /// Hard cap on a single take so a recording the user forgot about can't run
    /// the battery down. The finished file is queued, never dropped.
    private static let maxDuration: TimeInterval = 600

    private var recorder: AVAudioRecorder?
    private var current: (id: ULID, url: URL, startedAt: Date)?
    private var tickTask: Task<Void, Never>?
    /// Kept strongly — `AVAudioRecorder.delegate` is weak.
    private var recorderDelegate: RecorderDelegate?

    /// Directory the finished recordings (and their `.meta.plist` sidecars) live
    /// in until the phone confirms delivery. Persisted, so a queued file survives
    /// app suspension and can be re-enqueued on the next launch.
    static var recordingsDirectory: URL {
        URL.documentsDirectory.appending(path: "Recordings", directoryHint: .isDirectory)
    }

    /// Ask for microphone access, returning whether it was granted. Safe to call
    /// every time recording starts — the system only prompts once.
    func requestPermission() async -> Bool {
        let granted = await AVAudioApplication.requestRecordPermission()
        permissionDenied = !granted
        return granted
    }

    /// Whether the mic has been explicitly denied, so the UI can show a jump to
    /// Settings instead of a dead record button. Stored (not computed from
    /// `AVAudioApplication.shared.recordPermission`) so Observation tracks it and
    /// the UI actually flips to the denied state the moment a request is refused.
    private(set) var permissionDenied = AVAudioApplication.shared.recordPermission == .denied

    /// Begin recording into a fresh `<ULID>.m4a`. A no-op if already recording.
    /// Throws if the audio session or recorder can't be configured.
    func startRecording() async throws {
        guard !isRecording else { return }

        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.record, mode: .default)
        // watchOS requires the async activation (`setActive(true)` is not the
        // sanctioned path there and can leave the session without a live input,
        // yielding header-only files while `record()` still reports success).
        guard try await session.activate(options: []) else {
            throw RecorderError.couldNotStart
        }

        let dir = Self.recordingsDirectory
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)

        let id = ULID()
        let url = dir.appending(path: "\(id.stringValue).m4a")
        // Mirrors the phone recorder's proven settings (AudioRecorderService).
        let settings: [String: Any] = [
            AVFormatIDKey: kAudioFormatMPEG4AAC,
            AVSampleRateKey: 44_100.0,
            AVNumberOfChannelsKey: 1,
            AVEncoderBitRateKey: 48_000,
            AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
        ]

        let recorder = try AVAudioRecorder(url: url, settings: settings)
        let delegate = RecorderDelegate { [weak self] message in
            Task { @MainActor in self?.handleEncodeError(message) }
        }
        recorder.delegate = delegate
        recorderDelegate = delegate
        guard recorder.prepareToRecord(), recorder.record() else {
            throw RecorderError.couldNotStart
        }
        self.recorder = recorder
        self.current = (id, url, Date())
        isRecording = true
        elapsed = 0

        // Continuity through a wrist-down rests on the `audio` background mode
        // (declared in Info-watchOS.plist) keeping the active audio session
        // alive; the old `WKExtension.isFrontmostTimeoutExtended` knob is
        // deprecated and unsupported since watchOS 7. The stop-and-queue on
        // scene-background (see `WatchAppServices`) is the never-lose-audio net.
        WKInterfaceDevice.current().play(.start)
        startTicking()
    }

    /// Stop recording and return the finished file plus its duration, or nil if
    /// nothing was recording.
    @discardableResult
    func stopRecording() -> Recording? {
        guard let recorder, let current else { return nil }
        let duration = recorder.currentTime
        recorder.stop()
        WKInterfaceDevice.current().play(.stop)
        let recording = Recording(
            id: current.id, url: current.url, duration: duration, recordedAt: current.startedAt)
        teardown()
        // The watch simulator has no audio input device: record() reports
        // success and currentTime advances, but no file is ever written. Don't
        // hand a phantom recording up to the transfer queue.
        guard FileManager.default.fileExists(atPath: recording.url.path) else {
            Logger(subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "recording")
                .warning("recording produced no file (simulator has no mic input?): \(recording.url.lastPathComponent, privacy: .public)")
            return nil
        }
        // A capture that silently failed leaves a header-only m4a (tens of
        // bytes, currentTime frozen at 0). Surface it on the watch instead of
        // shipping a dead note to the phone.
        let size = ((try? FileManager.default.attributesOfItem(atPath: recording.url.path))?[.size] as? Int) ?? 0
        guard recording.duration > 0.1, size > 512 else {
            try? FileManager.default.removeItem(at: recording.url)
            Logger(subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "recording")
                .error("recording captured no audio (duration \(recording.duration), \(size) bytes)")
            onCaptureFailure?("No audio was captured. Check that Contextful has microphone access in the Watch app on iPhone.")
            return nil
        }
        return recording
    }

    /// Abandon the in-progress recording and delete its file. (Not surfaced in the
    /// single-button UI, but kept for symmetry / future use.)
    func discard() {
        recorder?.stop()
        if let url = current?.url { try? FileManager.default.removeItem(at: url) }
        teardown()
    }

    // MARK: - Metering / watchdog

    /// ~5 Hz loop that refreshes `elapsed` and enforces the safety cap. Runs on
    /// the main actor (a plain `Task` inherits it), so it can touch the
    /// non-Sendable recorder safely.
    private func startTicking() {
        tickTask?.cancel()
        tickTask = Task { [weak self] in
            while !Task.isCancelled {
                guard let self, let recorder = self.recorder, recorder.isRecording else { return }
                self.elapsed = recorder.currentTime
                if self.elapsed >= Self.maxDuration {
                    if let finished = self.stopRecording() {
                        self.onInvoluntaryFinish?(finished)
                    }
                    return
                }
                try? await Task.sleep(for: .milliseconds(200))
            }
        }
    }

    /// An encode error mid-take means the file is dead no matter how long the
    /// user keeps talking: stop, drop it, and surface the failure.
    private func handleEncodeError(_ message: String) {
        guard isRecording else { return }
        recorder?.stop()
        if let url = current?.url { try? FileManager.default.removeItem(at: url) }
        teardown()
        Logger(subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "recording")
            .error("encode error during recording: \(message, privacy: .public)")
        onCaptureFailure?("Recording failed: \(message)")
    }

    private func teardown() {
        tickTask?.cancel()
        tickTask = nil
        recorder?.delegate = nil
        recorder = nil
        recorderDelegate = nil
        current = nil
        isRecording = false
        elapsed = 0
        try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
    }

    enum RecorderError: Error {
        case couldNotStart
    }
}

/// Bridges `AVAudioRecorderDelegate` (nonisolated callbacks on a system queue)
/// to the main-actor recorder via a Sendable closure. Errors during an AAC
/// encode are only ever reported here — `record()` keeps returning success.
private final class RecorderDelegate: NSObject, AVAudioRecorderDelegate, Sendable {
    private let onError: @Sendable (String) -> Void

    init(onError: @escaping @Sendable (String) -> Void) {
        self.onError = onError
    }

    func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: (any Error)?) {
        onError(error?.localizedDescription ?? "unknown encode error")
    }

    func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
        if !flag { onError("the audio file could not be finalized") }
    }
}
