// tinyble — tiny-tech's Bluetooth helper for macOS (docs: tinyai-id docs/ble-mesh/SPEC.md).
//
//   tinyble scan [--seconds N] [--all] [--dupes]         JSONL sightings (tiny beacons only unless --all)
//   tinyble advertise --name NAME [--social] [--handle H] [--device-id ID] [--tools tools.json] [--session N]
//                                                        this Mac as a tiny (name carrier + Catalog GATT), until SIGINT
//   tinyble catalog <peripheral-uuid> [--hello '{json}']  connect, read identity/tools/endpoint → one JSON
//   tinyble version
//
// Why a Swift binary and not a Node module: CoreBluetooth is the only stable
// BLE stack on macOS; noble is central-only and bleno is dead on macOS ≥ 12.
// A bare binary gets the TCC Bluetooth grant of the terminal that runs it
// (RESEARCH §1.2). Output is JSONL so tiny-tech's `use_ble` tool can stream it.
import Foundation
import CoreBluetooth
import CryptoKit

let VERSION = "0.1.0"

func emit(_ o: [String: Any]) {
    let d = try! JSONSerialization.data(withJSONObject: o.compactMapValues { $0 is NSNull ? nil : $0 })
    print(String(data: d, encoding: .utf8)!); fflush(stdout)
}
func die(_ msg: String, code: Int32 = 2) -> Never { emit(["error": msg]); exit(code) }

struct Args {
    var cmd = ""
    var positional: [String] = []
    var flags: [String: String] = [:]
    init(_ a: [String]) {
        var it = a.dropFirst().makeIterator()
        if let c = it.next() { cmd = c }
        while let x = it.next() {
            if x.hasPrefix("--") {
                let k = String(x.dropFirst(2))
                if k == "all" || k == "dupes" || k == "social" { flags[k] = "1" } else { flags[k] = it.next() ?? "" }
            } else { positional.append(x) }
        }
    }
}

func beaconJSON(_ b: TinyBeaconV2) -> [String: Any] {
    var o: [String: Any] = ["v": 2, "kind": Int(b.kindRaw), "kindName": "\(b.kind)", "platform": b.kind.platform, "flags": Int(b.flags.rawValue),
                            "caps": Int(b.caps.rawValue), "tools": Int(b.tools), "session": Int(b.session), "rssi1m": Int(b.rssi1m), "carrier": b.carrier.rawValue,
                            "social": b.flags.contains(.social), "catalog": b.flags.contains(.catalog), "agent": b.flags.contains(.agent), "battery": b.flags.contains(.battery)]
    if let m = b.mine { o["mine"] = m }
    if let n = b.name { o["name"] = n }
    return o
}

// MARK: scan

final class Scanner: NSObject, CBCentralManagerDelegate {
    var central: CBCentralManager!
    let seconds: Double, all: Bool, dupes: Bool
    init(seconds: Double, all: Bool, dupes: Bool) {
        self.seconds = seconds; self.all = all; self.dupes = dupes; super.init()
        central = CBCentralManager(delegate: self, queue: nil)
    }
    func centralManagerDidUpdateState(_ c: CBCentralManager) {
        emit(["event": "state", "state": c.state.rawValue, "poweredOn": c.state == .poweredOn])
        guard c.state == .poweredOn else { if c.state == .unauthorized || c.state == .poweredOff { die("bluetooth \(c.state == .unauthorized ? "not authorized (System Settings → Privacy → Bluetooth)" : "off")") }; return }
        c.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: dupes])
        DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { c.stopScan(); emit(["event": "done"]); exit(0) }
    }
    func centralManager(_ c: CBCentralManager, didDiscover p: CBPeripheral, advertisementData ad: [String: Any], rssi: NSNumber) {
        let mfg = ad[CBAdvertisementDataManufacturerDataKey] as? Data
        let local = ad[CBAdvertisementDataLocalNameKey] as? String
        let uuids = ((ad[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]) ?? []).map { $0.uuidString }
        let parsed = TinyBeaconV2.decode(mfg: mfg, name: local, serviceUUIDs: uuids)
        if !all && parsed == nil { return }
        var row: [String: Any] = ["t": Date().timeIntervalSince1970, "id": p.identifier.uuidString, "name": local ?? p.name ?? "", "rssi": rssi.intValue,
                                  "mfg": mfg?.map { String(format: "%02x", $0) }.joined() ?? "", "uuids": uuids]
        if let tx = ad[CBAdvertisementDataTxPowerLevelKey] as? NSNumber { row["tx"] = tx.intValue }
        switch parsed {
        case .v2(let b)?: row["tiny"] = beaconJSON(b)
        case .v1(let i)?: row["tiny"] = ["v": 1, "kind": Int(i.version), "kindName": "\(i.kind)", "provisioned": i.provisioned]
        case nil: break
        }
        emit(row)
    }
}

// MARK: advertise

final class Advertiser: NSObject, CBPeripheralManagerDelegate {
    var pm: CBPeripheralManager!
    let display: String, social: Bool, handle: String?, deviceId: String?
    var tools: [[String: Any]]
    var session: UInt16
    let secret: Data
    var toolsPage = 0
    var helloSeen: Set<UUID> = []
    let toolsPerPage = 6

    init(display: String, social: Bool, handle: String?, deviceId: String?, tools: [[String: Any]], session: UInt16) {
        self.display = display; self.social = social; self.handle = handle; self.deviceId = deviceId; self.tools = tools
        self.session = session == 0 ? UInt16.random(in: 1...UInt16.max) : session
        // per-install secret for the `mine` tag (SPEC §2.2 bit 5); shared with the owner's other clients via the platform later
        let path = NSString(string: "~/.tiny/ble-mine-secret").expandingTildeInPath
        if let d = FileManager.default.contents(atPath: path), d.count == 32 { secret = d } else {
            var b = [UInt8](repeating: 0, count: 32); _ = SecRandomCopyBytes(kSecRandomDefault, 32, &b); secret = Data(b)
            try? secret.write(to: URL(fileURLWithPath: path)); chmod(path, 0o600)
        }
        super.init()
        pm = CBPeripheralManager(delegate: self, queue: nil)
        Timer.scheduledTimer(withTimeInterval: 15 * 60, repeats: true) { [unowned self] _ in self.session = UInt16.random(in: 1...UInt16.max); self.restart() }
    }
    var mineTag: String {
        let mac = HMAC<SHA256>.authenticationCode(for: Data([UInt8(session & 0xFF), UInt8(session >> 8)]), using: SymmetricKey(data: secret))
        return Base64URL.encode(Data(mac).prefix(3))
    }
    func header() -> TinyBeaconV2 {
        var flags: TinyBeaconV2.Flags = [.catalog, .agent, .mine, .provisioned]
        if social { flags.insert(.social) }
        return TinyBeaconV2(kind: .mac, flags: flags, caps: [.display, .speaker, .mic, .wifi, .relay, .touch], tools: tools.count, session: session, rssi1m: -59)
    }
    func devHash() -> String { deviceId.map { SHA256.hash(data: Data($0.utf8)).prefix(8).map { String(format: "%02x", $0) }.joined() } ?? "" }

    func peripheralManagerDidUpdateState(_ p: CBPeripheralManager) {
        emit(["event": "state", "state": p.state.rawValue, "poweredOn": p.state == .poweredOn])
        guard p.state == .poweredOn else { return }
        let svc = CBMutableService(type: CBUUID(string: TinyCatalog.serviceUUID), primary: true)
        svc.characteristics = [
            CBMutableCharacteristic(type: CBUUID(string: TinyCatalog.identity), properties: [.read], value: nil, permissions: [.readable]),
            CBMutableCharacteristic(type: CBUUID(string: TinyCatalog.tools), properties: [.read, .write], value: nil, permissions: [.readable, .writeable]),
            CBMutableCharacteristic(type: CBUUID(string: TinyCatalog.hello), properties: [.write, .writeWithoutResponse], value: nil, permissions: [.writeable]),
            CBMutableCharacteristic(type: CBUUID(string: TinyCatalog.endpoint), properties: [.read], value: nil, permissions: [.readable]),
        ]
        p.add(svc)
    }
    func peripheralManager(_ p: CBPeripheralManager, didAdd service: CBService, error: Error?) {
        if let error { die("add service: \(error.localizedDescription)") }
        restart()
    }
    func restart() {
        pm.stopAdvertising()
        do {
            let name = try header().encodeName(mine: mineTag, display: social ? display : nil)
            pm.startAdvertising([CBAdvertisementDataServiceUUIDsKey: [CBUUID(string: TinyCatalog.serviceUUID)], CBAdvertisementDataLocalNameKey: name])
            emit(["event": "advertising", "name": name, "session": Int(session), "mine": mineTag, "tools": tools.count, "social": social])
        } catch { die("encode: \(error)") }
    }
    func peripheralManagerDidStartAdvertising(_ p: CBPeripheralManager, error: Error?) {
        if let error { die("advertise: \(error.localizedDescription)") }
        emit(["event": "on_air"])
    }
    func identityJSON() -> Data {
        var o: [String: Any] = ["v": 2, "kind": Int(TinyBeaconV2.Kind.mac.rawValue), "dev": devHash(), "platform": "darwin-arm64", "fw": "tiny-tech tinyble \(VERSION)",
                                "tools": tools.count, "caps": Int(header().caps.rawValue), "session": Int(session), "host": ProcessInfo.processInfo.hostName]
        if social { o["name"] = display; if let handle { o["handle"] = handle } }
        return try! JSONSerialization.data(withJSONObject: o)
    }
    func toolsJSON(page: Int, hello: Bool) -> Data {
        if social && !hello { return Data(#"{"p":0,"n":0,"items":[],"err":"hello_first"}"#.utf8) }
        let pages = max(1, Int(ceil(Double(tools.count) / Double(toolsPerPage))))
        let p = min(max(0, page), pages - 1)
        let slice = Array(tools[(p * toolsPerPage)..<min(tools.count, (p + 1) * toolsPerPage)])
        // ATT value cap 512 B (Core Spec Vol 3 Part F §3.2.9): CoreBluetooth truncates longer reads
        // silently. Keep name + a clipped desc; drop desc when even that overflows. No URL on the wire.
        for descLen in [64, 40, 24, 0] {
            let items: [[String: String]] = slice.map { t in
                var o = ["name": (t["name"] as? String ?? "")]
                if descLen > 0, let d = t["desc"] as? String, !d.isEmpty { o["desc"] = String(d.prefix(descLen)) }
                return o
            }
            let d = try! JSONSerialization.data(withJSONObject: ["p": p, "n": pages, "items": items], options: [.sortedKeys])
            if d.count <= 512 || descLen == 0 { return d }
        }
        return Data("{}".utf8)
    }
    func endpointJSON() -> Data {
        var o: [String: Any] = [:]
        if let deviceId { o["device"] = "tinyapp://device/\(deviceId)" }
        if let z = ProcessInfo.processInfo.environment["TINY_ZENOH_LOCATOR"] { o["zenoh"] = z }
        return try! JSONSerialization.data(withJSONObject: o)
    }
    func peripheralManager(_ p: CBPeripheralManager, didReceiveRead r: CBATTRequest) {
        let value: Data
        switch r.characteristic.uuid.uuidString.uppercased() {
        case TinyCatalog.identity: value = identityJSON()
        case TinyCatalog.tools: value = toolsJSON(page: toolsPage, hello: helloSeen.contains(r.central.identifier))
        case TinyCatalog.endpoint: value = endpointJSON()
        default: p.respond(to: r, withResult: .attributeNotFound); return
        }
        guard r.offset <= value.count else { p.respond(to: r, withResult: .invalidOffset); return }
        r.value = value.subdata(in: r.offset..<value.count)
        p.respond(to: r, withResult: .success)
        if r.offset == 0 { emit(["event": "read", "char": r.characteristic.uuid.uuidString.lowercased(), "central": r.central.identifier.uuidString, "bytes": value.count]) }
    }
    func peripheralManager(_ p: CBPeripheralManager, didReceiveWrite rs: [CBATTRequest]) {
        for r in rs {
            switch r.characteristic.uuid.uuidString.uppercased() {
            case TinyCatalog.hello:
                helloSeen.insert(r.central.identifier)
                emit(["event": "hello", "central": r.central.identifier.uuidString, "json": String(decoding: (r.value ?? Data()).prefix(512), as: UTF8.self)])
            case TinyCatalog.tools: toolsPage = Int(r.value?.first ?? 0)
            default: break
            }
        }
        if let f = rs.first { p.respond(to: f, withResult: .success) }
    }
}

// MARK: catalog (client)

final class CatalogReader: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    var central: CBCentralManager!
    let target: UUID
    let hello: Data
    var peripheral: CBPeripheral?
    var chars: [String: CBCharacteristic] = [:]
    var out: [String: Any] = [:]
    var tools: [[String: Any]] = []
    var page = 0, pages = 1
    var stage = 0
    init(target: UUID, hello: Data) {
        self.target = target; self.hello = hello; super.init()
        central = CBCentralManager(delegate: self, queue: nil)
        DispatchQueue.main.asyncAfter(deadline: .now() + 25) { die("timeout") }
    }
    func centralManagerDidUpdateState(_ c: CBCentralManager) {
        guard c.state == .poweredOn else { return }
        if let p = c.retrievePeripherals(withIdentifiers: [target]).first { connect(p) }
        else {
            // not cached by this process yet — scan for it briefly
            c.scanForPeripherals(withServices: [CBUUID(string: TinyCatalog.serviceUUID)], options: nil)
        }
    }
    func centralManager(_ c: CBCentralManager, didDiscover p: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) {
        if p.identifier == target { c.stopScan(); connect(p) }
    }
    func connect(_ p: CBPeripheral) { peripheral = p; p.delegate = self; central.connect(p, options: nil) }
    func centralManager(_ c: CBCentralManager, didConnect p: CBPeripheral) { p.discoverServices([CBUUID(string: TinyCatalog.serviceUUID)]) }
    func centralManager(_ c: CBCentralManager, didFailToConnect p: CBPeripheral, error: Error?) { die("connect: \(error?.localizedDescription ?? "failed")") }
    func peripheral(_ p: CBPeripheral, didDiscoverServices error: Error?) {
        guard let s = p.services?.first else { die("no Tiny Catalog service") }
        p.discoverCharacteristics(nil, for: s)
    }
    func peripheral(_ p: CBPeripheral, didDiscoverCharacteristicsFor s: CBService, error: Error?) {
        for c in s.characteristics ?? [] { chars[c.uuid.uuidString.uppercased()] = c }
        guard let id = chars[TinyCatalog.identity] else { die("no identity characteristic") }
        p.readValue(for: id)
    }
    func peripheral(_ p: CBPeripheral, didWriteValueFor c: CBCharacteristic, error: Error?) {
        if c.uuid.uuidString.uppercased() == TinyCatalog.hello, let t = chars[TinyCatalog.tools] { p.readValue(for: t) }
        else if c.uuid.uuidString.uppercased() == TinyCatalog.tools, let t = chars[TinyCatalog.tools] { p.readValue(for: t) }
    }
    func peripheral(_ p: CBPeripheral, didUpdateValueFor c: CBCharacteristic, error: Error?) {
        if let error { die("read: \(error.localizedDescription)") }
        let d = c.value ?? Data()
        let obj = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any]
        if obj == nil, !d.isEmpty { out["parse_error"] = "\(c.uuid.uuidString.lowercased().suffix(4)): \(d.count) bytes not JSON (ATT 512 truncation?)" }
        switch c.uuid.uuidString.uppercased() {
        case TinyCatalog.identity:
            out["identity"] = obj ?? [:]
            if let h = chars[TinyCatalog.hello] { p.writeValue(hello, for: h, type: .withResponse) }
            else if let t = chars[TinyCatalog.tools] { p.readValue(for: t) } else { finish(p) }
        case TinyCatalog.tools:
            if let items = obj?["items"] as? [[String: Any]] { tools += items }
            pages = max(1, obj?["n"] as? Int ?? 1)
            if let e = obj?["err"] as? String { out["tools_error"] = e }
            if page + 1 < pages, let t = chars[TinyCatalog.tools] { page += 1; p.writeValue(Data([UInt8(page)]), for: t, type: .withResponse) }
            else if let e = chars[TinyCatalog.endpoint] { p.readValue(for: e) } else { finish(p) }
        case TinyCatalog.endpoint:
            out["endpoint"] = obj ?? [:]
            finish(p)
        default: break
        }
    }
    func finish(_ p: CBPeripheral) {
        out["tools"] = tools; out["id"] = target.uuidString
        central.cancelPeripheralConnection(p)
        emit(out); exit(0)
    }
}

// MARK: main

/// Keeps the active mode alive — CoreBluetooth delegates are weak.
var keepAlive: AnyObject?
let args = Args(CommandLine.arguments)
switch args.cmd {
case "version": emit(["tinyble": VERSION, "catalog": TinyCatalog.serviceUUID.lowercased()])
case "scan":
    keepAlive = Scanner(seconds: Double(args.flags["seconds"] ?? "10") ?? 10, all: args.flags["all"] != nil, dupes: args.flags["dupes"] != nil)
    RunLoop.main.run()
case "advertise":
    var tools: [[String: Any]] = []
    if let path = args.flags["tools"], let d = FileManager.default.contents(atPath: path),
       let arr = (try? JSONSerialization.jsonObject(with: d)) as? [[String: Any]] { tools = arr }
    keepAlive = Advertiser(display: args.flags["name"] ?? Host.current().localizedName ?? "Mac", social: args.flags["social"] != nil,
                   handle: args.flags["handle"], deviceId: args.flags["device-id"], tools: tools, session: UInt16(args.flags["session"] ?? "0") ?? 0)
    signal(SIGINT) { _ in emit(["event": "stopped"]); exit(0) }
    RunLoop.main.run()
case "catalog":
    guard let s = args.positional.first, let id = UUID(uuidString: s) else { die("usage: tinyble catalog <peripheral-uuid>") }
    let hello = Data((args.flags["hello"] ?? #"{"kind":5}"#).utf8)
    keepAlive = CatalogReader(target: id, hello: hello)
    RunLoop.main.run()
default:
    print("""
    tinyble \(VERSION) — tiny Bluetooth helper (macOS)
      scan [--seconds N] [--all] [--dupes]
      advertise --name NAME [--social] [--handle H] [--device-id ID] [--tools tools.json] [--session N]
      catalog <peripheral-uuid> [--hello '{json}']
      version
    """)
    exit(args.cmd.isEmpty ? 0 : 2)
}
