import AVFoundation
import Foundation
import IndieSync

/// Records a single voice note into a temporary `<ULID>.m4a` (AAC mono
/// 44.1 kHz ~48 kbps). Modeled on `ContextService`: `@Observable @MainActor`,
/// so the UI observes `isRecording` / `elapsed` / `level` directly.
///
/// Never records into the iCloud container — the file is written to the temp
/// directory and handed up as a URL; `SyncEngine.createAudioNote` is what copies
/// the finished bytes into the container (audio before JSON). `AVAudioRecorder`
/// is not Sendable, so it stays confined to the main actor for its whole life.
@Observable
@MainActor
final class AudioRecorderService {
    /// One finished recording: the temp file and how long it runs. The caller
    /// owns the file from here on (moves its bytes into the container, then
    /// discards the temp copy).
    struct Recording: Equatable {
        let url: URL
        let duration: TimeInterval
    }

    private(set) var isRecording = false
    /// Seconds recorded so far, updated by the metering loop while recording.
    private(set) var elapsed: TimeInterval = 0
    /// Normalized input level (0…1) for the level-bar UI; 0 when idle.
    private(set) var level: Float = 0

    /// Fired when a system interruption (phone call, Siri) forces recording to
    /// stop. The partial file is finalized and handed up rather than lost, so the
    /// UI can move straight to the "recorded" state. nil unless a recorder wires
    /// it up for the duration of a take.
    var onInterrupted: ((Recording) -> Void)?

    private var recorder: AVAudioRecorder?
    private var meterTask: Task<Void, Never>?
    private var currentURL: URL?

    #if os(iOS)
    private var interruptionObserver: (any NSObjectProtocol)?
    #endif

    /// 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 {
        await AVAudioApplication.requestRecordPermission()
    }

    /// Whether the mic has been explicitly denied (so the UI can show a jump to
    /// Settings instead of a dead record button).
    var permissionDenied: Bool {
        AVAudioApplication.shared.recordPermission == .denied
    }

    /// Begin recording into a fresh temp file. Throws if the audio session or
    /// recorder can't be configured (surfaced by the UI). A no-op if already
    /// recording.
    func startRecording() throws {
        guard !isRecording else { return }

        #if os(iOS)
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.playAndRecord, mode: .spokenAudio, options: [.duckOthers])
        try session.setActive(true)
        observeInterruptions()
        #endif

        let url = FileManager.default.temporaryDirectory
            .appending(path: "\(ULID().stringValue).m4a")
        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)
        recorder.isMeteringEnabled = true
        guard recorder.record() else {
            throw RecorderError.couldNotStart
        }
        self.recorder = recorder
        self.currentURL = url
        isRecording = true
        elapsed = 0
        level = 0
        startMetering()
    }

    /// Stop recording and return the finished file plus its duration, or nil if
    /// nothing was recording. Deactivates the audio session on iOS.
    @discardableResult
    func stopRecording() -> Recording? {
        guard let recorder, let url = currentURL else { return nil }
        let duration = recorder.currentTime
        recorder.stop()
        teardown()
        return Recording(url: url, duration: duration)
    }

    /// Abandon the in-progress (or just-finished) recording and delete its temp
    /// file. Used by "Discard" and when the composer is dismissed unsaved.
    func discard() {
        recorder?.stop()
        if let url = currentURL {
            try? FileManager.default.removeItem(at: url)
        }
        teardown()
    }

    // MARK: - Metering

    /// ~30 Hz loop that refreshes `elapsed` and `level` while recording. Runs on
    /// the main actor (a plain `Task` inherits this actor), so it can touch the
    /// non-Sendable recorder safely.
    private func startMetering() {
        meterTask?.cancel()
        meterTask = Task { [weak self] in
            while !Task.isCancelled {
                guard let self, let recorder = self.recorder, recorder.isRecording else { return }
                recorder.updateMeters()
                self.elapsed = recorder.currentTime
                self.level = Self.normalizedLevel(recorder.averagePower(forChannel: 0))
                try? await Task.sleep(for: .milliseconds(33))
            }
        }
    }

    /// Map a dBFS reading (roughly -60…0) to a 0…1 bar height.
    private static func normalizedLevel(_ power: Float) -> Float {
        let floorDB: Float = -50
        guard power > floorDB else { return 0 }
        return min(1, (power - floorDB) / -floorDB)
    }

    private func teardown() {
        meterTask?.cancel()
        meterTask = nil
        recorder = nil
        currentURL = nil
        isRecording = false
        elapsed = 0
        level = 0
        #if os(iOS)
        if let interruptionObserver {
            NotificationCenter.default.removeObserver(interruptionObserver)
            self.interruptionObserver = nil
        }
        try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
        #endif
    }

    // MARK: - Interruptions (iOS)

    #if os(iOS)
    private func observeInterruptions() {
        guard interruptionObserver == nil else { return }
        interruptionObserver = NotificationCenter.default.addObserver(
            forName: AVAudioSession.interruptionNotification,
            object: AVAudioSession.sharedInstance(),
            queue: .main
        ) { [weak self] note in
            // Extract the Sendable interruption type before hopping — the
            // Notification itself isn't Sendable. Delivered on the main queue, so
            // assuming main-actor isolation is sound.
            let began = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt)
                .flatMap(AVAudioSession.InterruptionType.init(rawValue:)) == .began
            guard began else { return }
            MainActor.assumeIsolated {
                self?.handleInterruptionBegan()
            }
        }
    }

    private func handleInterruptionBegan() {
        guard isRecording else { return }
        // Finalize rather than drop the take — the partial recording is still a
        // valid note.
        if let finished = stopRecording() {
            onInterrupted?(finished)
        }
    }
    #endif

    enum RecorderError: Error {
        case couldNotStart
    }
}
