import CoreGraphics
import Foundation
import NucleicMDM

// Host-side evidence for one Mode A attempt (docs/MACOS_VM_PROVISIONING_REPAIR_PLAN.md, Workstream 0).
//
// A live macOS 27 base build is a ~40-minute experiment, and until now a failed enrollment pass
// yielded one JPEG and one error string — not enough to tell "the guest never reached the server"
// apart from "the guest reached the server and we answered it wrongly", which are opposite fixes.
// This recorder collects, under ONE directory per attempt:
//
//   • `transcript.log` — every MDM protocol exchange from `NucleicMDMServer`'s log sink, plus the
//     orchestration's own step notes, timestamped. This is the file that settles the question above.
//   • `frame-NNN.jpg` / the recognized text of every approval-loop iteration — so a blank framebuffer
//     is visually obvious and a changed macOS wording is readable rather than inferred.
//   • `approval.json` — the last screen's recognized text, frontmost UI, and TCC log tail.
//
// Nothing here is load-bearing for provisioning: every method is best-effort and silent on failure,
// because losing evidence must never be the reason a base build fails.
extension MacVMEngine {
    /// Collects one attempt's evidence under `<base>/Diagnostics/MDM/run-<token>/`.
    ///
    /// Thread-safe by construction: the protocol log sink is invoked off-actor by the server's
    /// connection handlers while the engine actor is concurrently recording approval frames.
    final class MDMDiagnosticsRecorder: @unchecked Sendable {
        /// Keep the JPEG budget bounded — a 300 s approval loop at ~1.25 s/frame is ~240 captures, and
        /// writing them all would put tens of megabytes beside every base. Text is always recorded;
        /// images are written only when they carry new information (see ``recordFrame``).
        static let maxFrameImages = 40
        /// How many previous attempt directories to retain. Enough to compare a failure against the
        /// runs around it without the base bundle growing without bound.
        static let retainedRuns = 5

        let directory: URL
        /// The MDM server's own fine-grained trace (`mdm-server-debug.log` in this run's directory):
        /// listener lifecycle, per-connection state transitions, raw bytes, parsed requests, queue
        /// movement. Separate from ``protocolLogSink`` because the failure being chased — a guest whose
        /// TLS handshake fails — produces no HTTP request at all and is therefore invisible in the
        /// request-level transcript.
        let serverDebugLog: MDMDebugLog?
        private let lock = NSLock()
        private var handle: FileHandle?
        private var frameCount = 0
        private var blankFrames = 0
        private var imagesWritten = 0
        private var lastState: String?

        /// Create the run directory, or `nil` when it can't be made (evidence is best-effort).
        init?(bundle: MacVMBundle, label: String) {
            let fm = FileManager.default
            let root = bundle.mdmDiagnosticsURL
            let token = UUID().uuidString.lowercased().prefix(8)
            let run = root.appendingPathComponent("run-\(label)-\(token)", isDirectory: true)
            do {
                try fm.createDirectory(at: run, withIntermediateDirectories: true)
                try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: run.path)
            } catch {
                return nil
            }
            self.directory = run
            self.serverDebugLog = MDMDebugLog(
                url: run.appendingPathComponent("mdm-server-debug.log"))
            Self.pruneOldRuns(in: root, keeping: Self.retainedRuns, excluding: run)
            let transcript = run.appendingPathComponent("transcript.log")
            if fm.createFile(
                atPath: transcript.path, contents: nil,
                attributes: [.posixPermissions: 0o600])
            {
                handle = try? FileHandle(forWritingTo: transcript)
            }
            note("recording MDM attempt \"\(label)\"")
        }

        deinit { try? handle?.close() }

        // MARK: - Transcript

        /// The `NucleicMDMServer` log sink: mirrors every protocol exchange into the transcript while
        /// preserving the existing unified-log line. Safe to call from any thread.
        var protocolLogSink: @Sendable (String) -> Void {
            { [weak self] line in
                NSLog("[mdm] %@", line)
                self?.append("mdm  " + line)
            }
        }

        /// Record one orchestration step (the host's own view, interleaved with protocol lines).
        func note(_ line: String) { append("step " + line) }

        private func append(_ line: String) {
            let stamp = Self.stamp()
            lock.lock()
            defer { lock.unlock() }
            guard let handle else { return }
            try? handle.write(contentsOf: Data("[\(stamp)] \(line)\n".utf8))
        }

        // MARK: - Approval frames

        /// Record one approval-loop iteration. The recognized text always goes to the transcript; the
        /// image is written only when it can carry information the previous frames didn't — the first
        /// frame, any frame whose classified state changed, any frame we acted on, and any frame that
        /// recognized nothing (the blank-framebuffer signature) — and always within the image budget.
        ///
        /// `capture` is the frame the recognizer actually read: full guest resolution and losslessly
        /// encoded, so it is transcoded to a bounded JPEG on the way to disk. Its **pixel dimensions
        /// go into the transcript** — a recognition capture that arrives at the 1920×1200 input space
        /// rather than the guest's native panel means the framebuffer grab was downsampled before
        /// Vision ever saw it, which is the difference between clean OCR and the character
        /// substitutions that used to make this loop miss the controls it had to click. Without the
        /// number recorded, that is indistinguishable from "macOS changed its wording".
        ///
        /// Returns the frame index, so the caller can reference it in an error message.
        @discardableResult
        func recordFrame(
            capture: Data?, lines: [String], state: String, acted: Bool
        ) -> Int {
            lock.lock()
            frameCount += 1
            if lines.isEmpty { blankFrames += 1 }
            let index = frameCount
            let changed = state != lastState
            lastState = state
            let budgetLeft = imagesWritten < Self.maxFrameImages
            let interesting = index == 1 || changed || acted || lines.isEmpty
            let write = budgetLeft && interesting
            if write { imagesWritten += 1 }
            lock.unlock()

            let recognized = lines.isEmpty
                ? "<nothing recognized>"
                : lines.joined(separator: " ⏐ ")
            let measured = capture.flatMap { MacVMEngine.imageSize(ofCapture: $0) }
            let describedCapture: String
            switch (capture, measured) {
            case (nil, _): describedCapture = " capture=nil"
            case (let data?, let size?):
                describedCapture =
                    " capture=\(data.count)B \(Int(size.width))×\(Int(size.height))px"
            case (let data?, nil): describedCapture = " capture=\(data.count)B ?×?px"
            }
            append(
                "frame \(String(format: "%03d", index)) state=\(state)"
                    + (acted ? " acted" : "") + describedCapture
                    + " text: \(recognized)")

            if write, let capture {
                // Always land as a JPEG the size of a screenshot, whatever the recognizer was handed.
                let bytes = MacVMEngine.evidenceJPEG(from: capture) ?? capture
                let url = directory.appendingPathComponent(
                    "frame-\(String(format: "%03d", index)).jpg")
                try? bytes.write(to: url, options: .atomic)
                try? FileManager.default.setAttributes(
                    [.posixPermissions: 0o600], ofItemAtPath: url.path)
            }
            return index
        }

        /// Whether at least `count` frames have been recorded and every one of them recognized no text
        /// — the signature of a framebuffer that is captured but never rendered, which is a different
        /// failure (and a different fix) from a changed macOS wording.
        func onlyBlankFrames(atLeast count: Int) -> Bool {
            lock.lock()
            defer { lock.unlock() }
            return frameCount >= count && blankFrames == frameCount
        }

        // MARK: - Retention

        /// Delete all but the newest `keeping` run directories. Best-effort and never fatal.
        static func pruneOldRuns(in root: URL, keeping: Int, excluding: URL) {
            let fm = FileManager.default
            guard let entries = try? fm.contentsOfDirectory(
                at: root, includingPropertiesForKeys: [.contentModificationDateKey],
                options: [.skipsHiddenFiles])
            else { return }
            let runs = entries
                .filter { $0.lastPathComponent.hasPrefix("run-") && $0 != excluding }
                .sorted { a, b in
                    let da = (try? a.resourceValues(forKeys: [.contentModificationDateKey]))?
                        .contentModificationDate ?? .distantPast
                    let db = (try? b.resourceValues(forKeys: [.contentModificationDateKey]))?
                        .contentModificationDate ?? .distantPast
                    return da > db
                }
            guard runs.count > max(0, keeping) else { return }
            for stale in runs.dropFirst(max(0, keeping)) { try? fm.removeItem(at: stale) }
        }

        private static func stamp() -> String {
            let formatter = ISO8601DateFormatter()
            formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
            return formatter.string(from: Date())
        }
    }
}
