import AppKit
import Foundation
import os

/// Watches the main thread for unresponsiveness and, when it wedges, captures the evidence.
///
/// This exists because the app has beach-balled with **no artifact left behind**: on Jul 14 a
/// click on a transcript tool card blocked the main thread permanently (low CPU — a wait, not
/// a spin) until the user force-quit ~18s later. macOS wrote no spin/hang report for it, the
/// unified log only shows the main thread going silent, and by the time anyone can
/// investigate the process is gone. A watchdog that samples *from inside the hang* turns the
/// next occurrence into a definitive stack instead of another shrug.
///
/// Mechanism: a utility-QoS timer pings the main queue once a second and notes the pong.
/// When a ping has gone unanswered past ``hangThreshold``, it runs `/usr/bin/sample` against
/// our own pid (which needs no privileges for a same-user process and does not need the
/// target responsive) and writes the report under
/// `Application Support/Nucleic/hang-reports/`, then logs recovery — with the hang's
/// duration — once the main thread answers again. Reports are rate-limited per run and
/// pruned on disk, so a persistently sick machine can't fill the disk or fork `sample` in a
/// loop. The watchdog itself never touches the main thread beyond the one `async` ping.
final class MainThreadHangReporter: @unchecked Sendable {
    static let shared = MainThreadHangReporter()

    /// Seconds of main-thread silence before it counts as a hang. Above the ~2s a genuinely
    /// slow layout pass can take on a long transcript (see the Jul 12 spin report), so the
    /// watchdog fires on real wedges, not on a heavy-but-recovering frame.
    private static let hangThreshold: TimeInterval = 3
    /// Seconds `/usr/bin/sample` profiles once a hang is detected — long enough to prove a
    /// steady-state block, short enough to still capture a hang the user force-quits early.
    private static let sampleSeconds = 3
    /// At most this many reports per app run — a machine that keeps hanging shouldn't keep
    /// forking `sample`.
    private static let maxReportsPerRun = 3
    /// Newest reports kept on disk; older ones are pruned after each capture.
    private static let maxReportsOnDisk = 10

    private let log = Logger(subsystem: "com.nucleic", category: "hang")
    private let queue = DispatchQueue(label: "nucleic.hang-watchdog", qos: .utility)
    private var timer: DispatchSourceTimer?
    /// The per-tick ping, built once in `start()` and re-dispatched to the main queue each
    /// heartbeat. Prebuilt because `DispatchQueue.async { … }` evaluates its defaulted
    /// `flags: []` argument (a `DispatchWorkItemFlags` SetAlgebra init) at every call site,
    /// re-entering the runtime's generic-metadata cache — the Jul 28 hang sample caught the
    /// main thread 118 samples deep in that cache's lock inside this very closure. Dispatching
    /// a prebuilt item keeps the main-thread hop free of allocation and metadata lookups.
    private var pingItem: DispatchWorkItem?

    // Heartbeat state — touched only on `queue`.
    /// When the main thread last answered a ping.
    private var lastPong = Date()
    /// Whether a ping is currently waiting for the main thread to answer.
    private var pingInFlight = false
    /// When the current hang was first detected; nil while responsive.
    private var hangStart: Date?
    private var reportsThisRun = 0

    /// Starts the watchdog; safe to call once at launch. (Idempotent — a second call is a no-op.)
    func start() {
        queue.async { [self] in
            guard timer == nil else { return }
            lastPong = Date()
            // The pong runs on `queue`; the ping runs on main and only re-dispatches the pong.
            // Both are reused across every tick — see `pingItem`.
            let pong = DispatchWorkItem { [weak self] in
                guard let self else { return }
                self.pingInFlight = false
                self.lastPong = Date()
                if let start = self.hangStart {
                    self.hangStart = nil
                    let seconds = Date().timeIntervalSince(start)
                    self.log.error(
                        "Main thread recovered after \(seconds, format: .fixed(precision: 1))s hang")
                }
            }
            pingItem = DispatchWorkItem { [weak self] in
                guard let self else { return }
                self.queue.async(execute: pong)
            }
            let source = DispatchSource.makeTimerSource(queue: queue)
            source.schedule(deadline: .now() + 1, repeating: 1)
            source.setEventHandler { [weak self] in self?.tick() }
            source.resume()
            timer = source
        }
    }

    /// One heartbeat: send a ping if none is outstanding; otherwise measure the silence and
    /// capture once it crosses the threshold. Runs on `queue`.
    private func tick() {
        if pingInFlight {
            let silentFor = Date().timeIntervalSince(lastPong)
            if silentFor >= Self.hangThreshold, hangStart == nil {
                hangStart = lastPong
                captureHang(silentFor: silentFor)
            }
            return
        }
        pingInFlight = true
        if let pingItem { DispatchQueue.main.async(execute: pingItem) }
    }

    /// Samples our own process into a timestamped report file. Runs on `queue`; the `sample`
    /// child does the profiling, so the watchdog stays free to notice recovery.
    private func captureHang(silentFor: TimeInterval) {
        guard reportsThisRun < Self.maxReportsPerRun else { return }
        reportsThisRun += 1
        let dir = Self.reportsDirectory()
        let stamp = ISO8601DateFormatter().string(from: Date())
            .replacingOccurrences(of: ":", with: "-")
        let file = dir.appendingPathComponent("hang-\(stamp).txt")
        log.error(
            "Main thread unresponsive for \(silentFor, format: .fixed(precision: 1))s — sampling to \(file.path, privacy: .public)")
        let sample = Process()
        sample.executableURL = URL(fileURLWithPath: "/usr/bin/sample")
        sample.arguments = [
            String(ProcessInfo.processInfo.processIdentifier),
            String(Self.sampleSeconds), "-file", file.path,
        ]
        sample.standardOutput = FileHandle.nullDevice
        sample.standardError = FileHandle.nullDevice
        sample.terminationHandler = { [weak self] process in
            guard let self else { return }
            self.queue.async {
                if process.terminationStatus == 0 {
                    self.log.error("Hang sample written to \(file.path, privacy: .public)")
                } else {
                    self.log.error("Hang sample failed (exit \(process.terminationStatus))")
                }
                Self.pruneReports(in: dir)
            }
        }
        do {
            try sample.run()
        } catch {
            log.error("Could not launch /usr/bin/sample: \(String(describing: error), privacy: .public)")
        }
    }

    /// `Application Support/Nucleic/hang-reports`, created on demand.
    private static func reportsDirectory() -> URL {
        let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
        let dir = base.appendingPathComponent("Nucleic", isDirectory: true)
            .appendingPathComponent("hang-reports", isDirectory: true)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        return dir
    }

    /// Keeps only the newest ``maxReportsOnDisk`` reports.
    private static func pruneReports(in dir: URL) {
        guard let files = try? FileManager.default.contentsOfDirectory(
            at: dir, includingPropertiesForKeys: [.contentModificationDateKey])
        else { return }
        let sorted = files.sorted { lhs, rhs in
            let l = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]))?
                .contentModificationDate ?? .distantPast
            let r = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]))?
                .contentModificationDate ?? .distantPast
            return l > r
        }
        for stale in sorted.dropFirst(maxReportsOnDisk) {
            try? FileManager.default.removeItem(at: stale)
        }
    }
}
