//
//  CoreIKEventLog.swift
//  HelloVisionOS - Niall George Horn - June 2026
//
//  Logs every Apple private CoreIK function invocation to:
//    /tmp/coreik_invocations.log  — ANSI coloured, live-tailable from host:
//                                   tail -f /tmp/coreik_invocations.log
//    /tmp/coreik_report.md        — Markdown summary, written on demand
//
//  The simulator's /tmp maps directly to the host's /private/tmp, so these
//  files are readable from a separate host terminal while the app is running.
//

import Foundation
import Darwin  // backtrace(), backtrace_symbols()

// ANSI colour constants

private enum C {
    static let reset   = "\u{1B}[0m"
    static let bold    = "\u{1B}[1m"
    static let red     = "\u{1B}[31m"   // lifecycle  (create / destroy)
    static let yellow  = "\u{1B}[33m"   // pose inputs (setVioPose / setSource)
    static let green   = "\u{1B}[32m"   // solve ✅
    static let cyan    = "\u{1B}[36m"   // misc / addresses
    static let magenta = "\u{1B}[35m"   // runtime patch / interpose
    static let gray    = "\u{1B}[90m"   // timestamps / low-signal
}

// CoreIKEventLog

/// Thread-safe singleton.  Call from any thread — a private serial queue
/// serialises all file writes.
final class CoreIKEventLog {

    static let shared = CoreIKEventLog()

    private let queue = DispatchQueue(label: "coreik.eventlog", qos: .utility)
    private var logHandle: FileHandle?
    private let logPath   = "/tmp/coreik_invocations.log"
    private let reportPath = "/tmp/coreik_report.md"

    private var startTime = Date()

    // Invocation counters (written only on queue)
    private var createCount  = 0
    private var setVioCount  = 0
    private var setSrcCount  = 0
    private var solveCount   = 0
    private var destroyCount = 0
    private var interceptCount = 0

    // Metadata set once CoreIK is loaded
    private var coreikBase: UInt   = 0
    private var coreikPath: String = "unknown"

    private init() {
        queue.async { self.openLog() }
    }

    // Open log file

    private func openLog() {
        FileManager.default.createFile(atPath: logPath, contents: nil)
        logHandle = FileHandle(forWritingAtPath: logPath)
        startTime = Date()

        let ts = ISO8601DateFormatter().string(from: startTime)
        // Name the build we're actually running against, read live from the staged tag.
        let buildTag = (try? String(contentsOfFile: "/tmp/CoreIK.framework/BUILD", encoding: .utf8))?
            .trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown"
        let osVersion = ["23N301": "26.2", "23O249a": "26.4.1", "23O470": "26.5"][buildTag] ?? "unknown"
        let verPadded = ("  Apple visionOS \(osVersion) (build \(buildTag)) — Persona Body IK Stack")
            .padding(toLength: 66, withPad: " ", startingAt: 0)
        let header = """
            \(C.bold)\(C.magenta)╔══════════════════════════════════════════════════════════════════╗
            ║  CoreIK Private API — Invocation Log                             ║
            ║\(verPadded)║
            ║  Calling Apple's production IK solver WITHOUT Vision Pro hardware ║
            ╚══════════════════════════════════════════════════════════════════╝\(C.reset)
            \(C.gray)Started : \(ts)\(C.reset)
            \(C.gray)Log file: \(logPath)\(C.reset)
            \(C.gray)Report  : \(reportPath)  (written on demand via Export Report button)\(C.reset)

            \(C.magenta)── Runtime Patch Applied ────────────────────────────────────────────\(C.reset)
            \(C.cyan)  Target  :\(C.reset) CFURLCopyPath  (Apple CoreFoundation)
            \(C.cyan)  Replaced:\(C.reset) CoreIKLogger_CFURLCopyPath  (our wrapper)
            \(C.cyan)  Method  :\(C.reset) __DATA_CONST,__interpose in HelloVisionOS.debug.dylib
            \(C.cyan)  Reason  :\(C.reset) ikinema::getResourcePath inside CoreIK calls CFURLCopyPath
                        (URL-encoded). Space in "xrOS \(osVersion).simruntime" → "%20"
                        → open() fails silently → solver creation returns nil.
            \(C.green)  Result  :\(C.reset) Patch bypasses Apple's URL-encoding, solver loads cleanly.
            \(C.magenta)─────────────────────────────────────────────────────────────────────\(C.reset)

            """
        write(header)
    }

    // Metadata

    /// Called once CoreIK is dlopen'd and the ASLR base is known.
    func setBase(_ base: UInt, path: String) {
        queue.async {
            self.coreikBase = base
            self.coreikPath = path
            let msg =
                "\(C.gray)CoreIK loaded\(C.reset)" +
                "  base=\(C.cyan)0x\(String(base, radix: 16))\(C.reset)" +
                "  path=\(C.cyan)\(path)\(C.reset)\n"
            self.write(msg)
        }
    }

    // Function-call events

    func logCreate(jointCount: Int, result: UnsafeMutableRawPointer?) {
        queue.async {
            self.createCount += 1
            let addr = result.map { "0x" + String(UInt(bitPattern: $0), radix: 16) } ?? "nil"
            let ok   = result != nil ? "\(C.green)OK\(C.reset)" : "\(C.red)FAILED\(C.reset)"
            self.write(
                "\(self.ts()) \(C.red)\(C.bold)▶ _CoreIKSolverFusionCreate\(C.reset)" +
                "(bodySourceType=0, jointCount=\(jointCount))" +
                " → solver@\(C.cyan)\(addr)\(C.reset) \(ok)\n"
            )
        }
    }

    func logSetVioPose(qx: Float, qy: Float, qz: Float, qw: Float,
                       tx: Float, ty: Float, tz: Float) {
        queue.async {
            self.setVioCount += 1
            // Log first 3 calls verbosely, then every ~5 s (300 frames)
            guard self.setVioCount <= 3 || self.setVioCount % 300 == 0 else { return }
            self.write(
                "\(self.ts()) \(C.yellow)▶ _CoreIKSolverFusionSetVioPoseV2\(C.reset)" +
                "(rot=(\(f(qx)),\(f(qy)),\(f(qz)),\(f(qw)))" +
                " pos=(\(f(tx)),\(f(ty)),\(f(tz))))" +
                " \(C.gray)[call #\(self.setVioCount)]\(C.reset)\n"
            )
        }
    }

    func logSetSource(group: Int32, tx: Float, ty: Float, tz: Float, conf: Float) {
        queue.async {
            self.setSrcCount += 1
            guard self.setSrcCount <= 6 || self.setSrcCount % 600 == 0 else { return }
            let side: String
            switch group {
            case 2:  side = "LEFT_WRIST "
            case 3:  side = "RIGHT_WRIST"
            default: side = "group=\(group)   "
            }
            self.write(
                "\(self.ts()) \(C.yellow)▶ _CoreIKSolverFusionSetSourceV2\(C.reset)" +
                "(\(side) pos=(\(f(tx)),\(f(ty)),\(f(tz))) conf=\(f(conf)))" +
                " \(C.gray)[call #\(self.setSrcCount)]\(C.reset)\n"
            )
        }
    }

    func logSolve(quality: Double, jointCount: Int) {
        queue.async {
            self.solveCount += 1
            let first3  = self.solveCount <= 3
            let periodic = self.solveCount % 300 == 0
            let q = String(format: "%.4f", quality)

            if first3 || periodic {
                self.write(
                    "\(self.ts()) \(C.green)\(C.bold)✅ _CoreIKSolverFusionSolveSources\(C.reset)" +
                    "(format=1, outputBuf, confBuf, jointCount=\(jointCount))" +
                    " \(C.green)→ quality=\(q)\(C.reset)" +
                    " \(C.gray)[solve #\(self.solveCount)]\(C.reset)\n"
                )
            }

            // Capture backtrace on the very first solve
            if self.solveCount == 1 {
                self.captureBacktrace()
            }

            // Auto-write report so coreik_reports alias always has current data
            let n = self.solveCount
            if n == 1 || n == 1_000 || n % 10_000 == 0 {
                self.writeReportAsync()
            }
        }
    }

    func logDestroy() {
        queue.async {
            self.destroyCount += 1
            self.write(
                "\(self.ts()) \(C.red)▶ _CoreIKSolverFusionDestroy\(C.reset)(solver)" +
                " \(C.gray)[#\(self.destroyCount)]\(C.reset)\n"
            )
        }
    }

    /// Called from OpenInterpose.m (via a global C shim) when CFURLCopyPath fires.
    func logIntercept(original: String, patched: String) {
        queue.async {
            self.interceptCount += 1
            guard self.interceptCount <= 5 else { return }
            self.write(
                "\(self.ts()) \(C.magenta)⚡ INTERPOSE CFURLCopyPath\(C.reset)" +
                "  url-encoded=\(C.red)\"\(original)\"\(C.reset)" +
                " → patched=\(C.green)\"\(patched)\"\(C.reset)" +
                " \(C.gray)[#\(self.interceptCount)]\(C.reset)\n"
            )
        }
    }

    // Call-stack capture (first solve only)

    private func captureBacktrace() {
        var addrs = [UnsafeMutableRawPointer?](repeating: nil, count: 32)
        let n = backtrace(&addrs, 32)

        var out = "\n\(C.cyan)── Call stack at Solve #1 (proving entry into Apple's CoreIK binary) ──\(C.reset)\n"

        if let syms = backtrace_symbols(&addrs, n) {
            for i in 0..<Int(n) {
                guard let sym = syms[i] else { continue }
                let s = String(cString: sym)
                if s.contains("CoreIK") {
                    out += "\(C.green)  \(s)\(C.reset)\n"
                } else if s.contains("HelloVisionOS") {
                    out += "  \(s)\n"
                } else {
                    out += "\(C.gray)  \(s)\(C.reset)\n"
                }
            }
            free(syms)
        }

        if coreikBase > 0 {
            out += "\(C.cyan)  CoreIK base     : 0x\(String(coreikBase, radix: 16))\(C.reset)\n"
            out += "\(C.cyan)  SolveSources    : base + per-build nm offset (see deploy/coreik_offsets/)\(C.reset)\n"
        }
        out += "\(C.cyan)────────────────────────────────────────────────────────────────────\(C.reset)\n\n"
        write(out)
    }

    // Markdown report

    /// Called from within the log queue — dispatches report write to a separate thread
    /// so it doesn't stall log I/O.
    private func writeReportAsync() {
        DispatchQueue.global(qos: .utility).async {
            _ = self.generateReport()
        }
    }

    @discardableResult
    func generateReport() -> String {
        // Wait for queue to drain so counts are final
        queue.sync { }

        let elapsed = Date().timeIntervalSince(startTime)
        let mins    = Int(elapsed) / 60
        let secs    = Int(elapsed) % 60
        let avgFps  = elapsed > 0 ? String(format: "%.1f", Double(solveCount) / elapsed) : "—"
        let total   = createCount + setVioCount + setSrcCount + solveCount + destroyCount + interceptCount

        // Read the actual staged build so the report names the right version/offsets,
        // not a hardcoded one. (setup.sh writes the build tag to this file.)
        let buildTag = (try? String(contentsOfFile: "/tmp/CoreIK.framework/BUILD", encoding: .utf8))?
            .trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown"
        let osVersion = ["23N301": "26.2", "23O249a": "26.4.1", "23O470": "26.5"][buildTag] ?? "unknown"

        let report = """
            # CoreIK Private API — Invocation Report
            **Framework:** Apple CoreIK (Private) — visionOS \(osVersion) / build \(buildTag)
            **Binary:** `\(coreikPath)`
            **ASLR base:** `0x\(String(coreikBase, radix: 16))`
            **Session duration:** \(mins)m \(secs)s
            **Hardware required:** None — running in visionOS Simulator on macOS (no Vision Pro)

            ---

            ## Runtime Patch Applied

            | Target | Replacement | Method | Reason |
            |--------|-------------|--------|--------|
            | `CFURLCopyPath` (Apple CoreFoundation) | `CoreIKLogger_CFURLCopyPath` (our wrapper) | `__DATA_CONST,__interpose` in app dylib | `ikinema::getResourcePath` inside CoreIK calls `CFURLCopyPath` (URL-encoded). Space in "xrOS \(osVersion).simruntime" becomes `%20`, passed to `open()` which fails silently → solver creation returns nil. Patch replaces with `CFURLCopyFileSystemPath` (space-safe). Apple's own binary patched at runtime without modifying the binary on disk. |

            ---

            ## Private API Invocation Counts

            | Apple Private Symbol | Invocations | Notes |
            |----------------------|-------------|-------|
            | `_CoreIKCreateSolverFusionInstanceWithEnrollmentLengths` | \(createCount) | Solver instance created; bone lengths injected |
            | `_CoreIKSolverFusionSetVioPoseV2` | \(setVioCount) | Head VIO pose inputs (position + quaternion) |
            | `_CoreIKSolverFusionSetSourcePoseV2` | \(setSrcCount) | Wrist end-effector inputs (LEFT + RIGHT combined) |
            | `_CoreIKSolverFusionSolveSources` | \(solveCount) | Full-body IK solves (avg \(avgFps) solves/sec over the session) |
            | `_CoreIKDestroySolverFusion` | \(destroyCount) | Solver cleanup |
            | `CFURLCopyPath` intercepts | \(interceptCount) | Apple-internal calls caught by our runtime patch |
            | **Total** | **\(total)** | |

            ---

            ## What This Demonstrates

            - Apple's production Persona body IK solver (`FIK::Fusion::FusedConfidenceSolver`) was invoked **\(solveCount) times** in this session
            - Each solve ingests 3 sparse anchor inputs (head VIO pose + 2 wrist positions) and outputs a **\(solveCount > 0 ? "91" : "?")-joint full-body skeletal pose**
            - Loaded via `dlopen` + raw nm-offset function pointers against Apple's **unstripped simulator binary** (no source code, no headers, no SDK access)
            - **No Vision Pro hardware present** — no `arkitd`, no `personacamerad`, no R1 coprocessor, no camera sensors
            - Apple's own binary was patched at runtime (`CFURLCopyPath` interpose) to operate outside its intended deployment context
            - Symbol addresses sourced from Apple's own developer tooling (MobileAsset CDN distribution of the visionOS \(osVersion) simulator)
            - All of the above was accomplished using only Apple's standard developer tools: `nm --demangle`, `otool`, `xcrun simctl`, `lldb`

            ---

            ## Symbol Resolution

            Symbols were resolved by `nm` file-offset + ASLR slide against the unstripped binary.
            The per-build file offsets for `\(buildTag)` are recorded in
            `deploy/coreik_offsets/\(buildTag).txt`; the runtime ASLR base for this session was
            `0x\(String(coreikBase, radix: 16))`.

            ---

            *Generated by HelloVisionOS — CoreIK Tribunal Demo*
            *Log: \(logPath)*
            """

        try? report.write(toFile: reportPath, atomically: true, encoding: .utf8)
        return report
    }

    // Helpers

    private func write(_ s: String) {
        guard let data = s.data(using: .utf8),
              let fh = logHandle else { return }
        fh.write(data)
    }

    private func ts() -> String {
        let el = Date().timeIntervalSince(startTime)
        let m  = Int(el) / 60
        let s  = Int(el) % 60
        let ms = Int((el - Double(Int(el))) * 1000)
        return "\(C.gray)[\(String(format: "%02d:%02d.%03d", m, s, ms))]\(C.reset)"
    }
}

private func f(_ v: Float) -> String { String(format: "%.2f", v) }
