/*
 * MIRROR of tinyai-id ios/Tiny/Sources/TinyBeaconV2.swift — keep byte-identical
 * apart from this banner and the TinyBeaconInfo stub at the bottom
 * (native/tinyble/sync-check.sh diffs them). Same fixtures.json contract.
 *
 * TinyBeaconV2 — codec for the v2 beacon (docs/ble-mesh/SPEC.md §2) and the
 * Tiny Catalog GATT UUIDs (§3). Pure Foundation, no CoreBluetooth: the
 * twin of lib/ble/tiny-beacon.ts — both round-trip docs/ble-mesh/fixtures.json
 * (ios/Tests/TinyBeaconV2Tests.swift). Bluetooth.swift keeps the v1 parser
 * (TinyBeaconInfo); Nearby tries v2 first, then v1.
 *
 * Two carriers for one 9-byte header: manufacturer data (firmwares, Linux,
 * Android, ESP32) and the local NAME ("tn2." + base64url) — the only thing an
 * iOS/macOS CBPeripheralManager can put on air besides service UUIDs.
 */
import Foundation

enum TinyCatalog {
    static let serviceUUID = "F0E60BA4-B906-41FB-9D15-BB1312D27564"
    static let identity = "00000001-B906-41FB-9D15-BB1312D27564"
    static let tools    = "00000002-B906-41FB-9D15-BB1312D27564"
    static let hello    = "00000003-B906-41FB-9D15-BB1312D27564"
    static let endpoint = "00000004-B906-41FB-9D15-BB1312D27564"
    static let niToken  = "00000005-B906-41FB-9D15-BB1312D27564"
}

struct TinyBeaconV2: Equatable {
    static let legacyCompanyId: UInt16 = 0xFFFF
    static let version2: UInt8 = 0x10
    static let namePrefix = "tn2."
    static let headerLen = 9
    static let rssi1mUnknown: Int8 = 0x7F
    static let rssi1mDefault: Int8 = -59

    enum Kind: UInt8 {
        case unknown = 0, niclaVision = 1, niclaVoice = 2, niclaSense = 3, iphone = 4, mac = 5, android = 6
        case linuxNode = 7, sticky = 8, ring = 9, reachy = 10, scout = 11, fomo = 12, qBrain = 13, microduck = 14
        case wisp = 15, flipper = 16, pineapple = 17, flyNeo = 18, glasses = 19, printer = 20, lamp = 21, leaf = 22
        case spidey = 23, ipad = 24, watch = 25

        /// Fleet platform string (SPEC §2.1) — keep in sync with KIND_PLATFORM in tiny-beacon.ts.
        var platform: String {
            switch self {
            case .unknown: return "unknown"
            case .niclaVision: return "nicla-vision"; case .niclaVoice: return "nicla-voice"; case .niclaSense: return "nicla-sense"
            case .iphone, .ipad: return "ios-arm64"; case .mac: return "darwin-arm64"; case .android: return "android-arm64"
            case .linuxNode: return "linux-arm64"; case .sticky: return "esp32s3"; case .ring: return "ring"; case .reachy: return "reachy-mini"
            case .scout: return "scout-the-rover"; case .fomo: return "fomo-the-arm"; case .qBrain: return "q-the-brain"; case .microduck: return "microduck"
            case .wisp: return "wisp-the-crazyflie"; case .flipper: return "flipper-zero"; case .pineapple: return "openwrt-mips"; case .flyNeo: return "fly-the-neo"
            case .glasses: return "neon-the-g1"; case .printer: return "bambu-x2d"; case .lamp: return "lamp"; case .leaf: return "leaf-the-sprout"
            case .spidey: return "spidey"; case .watch: return "watchos"
            }
        }
        /// SF Symbol for the Nearby row (chrome uses symbols, not emoji — PLAYBOOK §9).
        var symbol: String {
            switch self {
            case .iphone: return "iphone"; case .ipad: return "ipad"; case .mac: return "desktopcomputer"; case .android: return "smartphone"
            case .watch: return "applewatch"; case .sticky: return "rectangle.portrait.on.rectangle.portrait"; case .ring: return "circle.dashed"
            case .reachy, .fomo, .scout, .spidey: return "figure.walk.motion"; case .glasses: return "eyeglasses"; case .printer: return "printer"
            case .lamp: return "lamp.desk"; case .leaf: return "leaf"; case .wisp, .flyNeo: return "airplane"; case .flipper: return "dolphin"
            case .pineapple: return "wifi.router"; case .linuxNode, .qBrain, .microduck: return "cpu"
            case .niclaVision: return "camera.viewfinder"; case .niclaVoice: return "waveform"; case .niclaSense: return "sensor"
            case .unknown: return "dot.radiowaves.left.and.right"
            }
        }
        /// A person's client — display name only when `social` (SPEC §4.3).
        var isPerson: Bool { [.iphone, .ipad, .mac, .android, .watch].contains(self) }
    }

    struct Flags: OptionSet, Equatable {
        let rawValue: UInt8
        static let provisioned = Flags(rawValue: 1 << 0)
        static let social      = Flags(rawValue: 1 << 1)
        static let catalog     = Flags(rawValue: 1 << 2)
        static let battery     = Flags(rawValue: 1 << 3)
        static let extadv      = Flags(rawValue: 1 << 4)
        static let mine        = Flags(rawValue: 1 << 5)
        static let agent       = Flags(rawValue: 1 << 6)
        static let reserved    = Flags(rawValue: 1 << 7)
    }

    struct Caps: OptionSet, Equatable {
        let rawValue: UInt16
        static let camera = Caps(rawValue: 1 << 0), mic = Caps(rawValue: 1 << 1), speaker = Caps(rawValue: 1 << 2)
        static let display = Caps(rawValue: 1 << 3), imu = Caps(rawValue: 1 << 4), gps = Caps(rawValue: 1 << 5)
        static let motors = Caps(rawValue: 1 << 6), lora = Caps(rawValue: 1 << 7), wifi = Caps(rawValue: 1 << 8)
        static let uwb = Caps(rawValue: 1 << 9), health = Caps(rawValue: 1 << 10), env = Caps(rawValue: 1 << 11)
        static let touch = Caps(rawValue: 1 << 12), eink = Caps(rawValue: 1 << 13), relay = Caps(rawValue: 1 << 14)
        /// Glyphs for the row, in a stable order.
        var symbols: [String] {
            var out: [String] = []
            if contains(.camera) { out.append("camera") }; if contains(.mic) { out.append("mic") }
            if contains(.speaker) { out.append("speaker.wave.2") }; if contains(.display) { out.append(contains(.eink) ? "doc.text.image" : "display") }
            if contains(.imu) { out.append("gyroscope") }; if contains(.gps) { out.append("location") }
            if contains(.motors) { out.append("gearshape.2") }; if contains(.lora) { out.append("antenna.radiowaves.left.and.right") }
            if contains(.wifi) { out.append("wifi") }; if contains(.uwb) { out.append("dot.radiowaves.up.forward") }
            if contains(.health) { out.append("heart") }; if contains(.env) { out.append("thermometer.medium") }
            if contains(.touch) { out.append("hand.tap") }; if contains(.relay) { out.append("arrow.triangle.branch") }
            return out
        }
    }

    /// `witness` = only the Catalog UUID was on air (a macOS CBPeripheralManager drops the local
    /// name and cannot emit mfg data — proven from the UNO Q: the Mac showed the UUID and no name).
    /// Header fields are all zero; the peer is real but must be resolved via the Catalog identity read.
    enum Carrier: String, Equatable { case mfg, name, short, witness }

    var version: UInt8
    var kindRaw: UInt8
    var flags: Flags
    var caps: Caps
    var tools: UInt8
    var session: UInt16
    var rssi1m: Int8
    var carrier: Carrier = .mfg
    var mine: String? = nil
    var name: String? = nil

    var kind: Kind { Kind(rawValue: kindRaw) ?? .unknown }

    init(version: UInt8 = TinyBeaconV2.version2, kind: Kind, flags: Flags, caps: Caps, tools: Int, session: UInt16, rssi1m: Int8, carrier: Carrier = .mfg, mine: String? = nil, name: String? = nil) {
        self.version = version; self.kindRaw = kind.rawValue; self.flags = flags; self.caps = caps
        self.tools = UInt8(clamping: max(0, tools)); self.session = session; self.rssi1m = rssi1m
        self.carrier = carrier; self.mine = mine; self.name = name
    }
    init(version: UInt8, kindRaw: UInt8, flags: Flags, caps: Caps, tools: UInt8, session: UInt16, rssi1m: Int8, carrier: Carrier) {
        self.version = version; self.kindRaw = kindRaw; self.flags = flags; self.caps = caps
        self.tools = tools; self.session = session; self.rssi1m = rssi1m; self.carrier = carrier
    }

    enum CodecError: Error, Equatable { case badVersion, reservedFlag, mineTag, displayNeedsSocial, displayHasDot, badRssi }

    // MARK: header bytes (§2)

    func encodeHeader() throws -> Data {
        guard (0x10...0x1F).contains(version) else { throw CodecError.badVersion }
        guard !flags.contains(.reserved) else { throw CodecError.reservedFlag }
        guard rssi1m == Self.rssi1mUnknown || rssi1m <= 20 else { throw CodecError.badRssi }
        return Data([version, kindRaw, flags.rawValue, UInt8(caps.rawValue & 0xFF), UInt8(caps.rawValue >> 8),
                     tools, UInt8(session & 0xFF), UInt8(session >> 8), UInt8(bitPattern: rssi1m)])
    }

    static func decodeHeader(_ b: Data, carrier: Carrier = .mfg) -> TinyBeaconV2? {
        guard b.count >= headerLen else { return nil }
        let d = [UInt8](b.prefix(headerLen))
        guard (0x10...0x1F).contains(d[0]), d[2] & 0x80 == 0 else { return nil }
        return TinyBeaconV2(version: d[0], kindRaw: d[1], flags: Flags(rawValue: d[2]),
                            caps: Caps(rawValue: UInt16(d[3]) | UInt16(d[4]) << 8), tools: d[5],
                            session: UInt16(d[6]) | UInt16(d[7]) << 8, rssi1m: Int8(bitPattern: d[8]), carrier: carrier)
    }

    // MARK: manufacturer-data carrier (§2.4)

    /// FULL form as CoreBluetooth hands it over: company id (LE) + 'T''N' + header.
    /// FULL form. `mineTail` (§2.4, 2026-09-21): a device with no name carrier
    /// (Linux, Sticky) appends the 3 raw HMAC bytes behind the 4-char `mine` tag
    /// so "your UNO Q" resolves from mfg data alone. Only with flags.mine.
    func encodeMfg(companyId: UInt16 = TinyBeaconV2.legacyCompanyId, mineTail: Data? = nil) throws -> Data {
        var d = Data([UInt8(companyId & 0xFF), UInt8(companyId >> 8)])
        if companyId == Self.legacyCompanyId { d.append(contentsOf: [0x54, 0x4E]) }
        d.append(try encodeHeader())
        if let t = mineTail {
            guard t.count == 3, flags.contains(.mine) else { throw CodecError.mineTag }
            d.append(t)
        }
        return d
    }
    /// SHORT form — only valid in the same PDU as the Catalog service UUID.
    func encodeMfgShort(companyId: UInt16 = TinyBeaconV2.legacyCompanyId) throws -> Data {
        var d = Data([UInt8(companyId & 0xFF), UInt8(companyId >> 8)])
        d.append(try encodeHeader().prefix(6))
        return d
    }

    enum Parsed: Equatable { case v2(TinyBeaconV2), v1(TinyBeaconInfo) }

    /// v2 first, then v1 (TinyBeaconInfo.parse). `hasCatalogUuid` = the Catalog UUID was in the same advertisement.
    static func decodeMfg(_ mfg: Data?, hasCatalogUuid: Bool = false, companyIds: [UInt16] = [legacyCompanyId]) -> Parsed? {
        guard let d = mfg, d.count >= 4 else { return nil }
        let bytes = [UInt8](d)
        let id = UInt16(bytes[0]) | UInt16(bytes[1]) << 8
        guard companyIds.contains(id) else { return nil }
        var body = Array(bytes[2...])
        if id == legacyCompanyId {
            if body.count >= 8, body[6] == 0x54, body[7] == 0x4E {
                // CoreBluetooth concatenates ADV_IND + SCAN_RSP mfg data into one value:
                // <id> SHORT(6) 'T''N' FULL(9[+3]) — measured on a Sticky 2026-09-21
                // (read as one header: session 0x4E54, rssi1m 16, wrong mine). Skip the SHORT.
                body = Array(body[8...])
            } else if body[0] == 0x54, body[1] == 0x4E {
                body = Array(body[2...])
                if let first = body.first, first < 0x10 {
                    return TinyBeaconInfo.parse(d).map { .v1($0) }
                }
            } else if !hasCatalogUuid {
                return nil // 0xFFFF without magic and without our UUID = someone else's test packet
            }
        }
        if var full = decodeHeader(Data(body), carrier: .mfg) {
            // §2.4 mine tail: 3 bytes after the header, only when flags.mine says so.
            if full.flags.contains(.mine), body.count >= headerLen + 3 { full.mine = Base64URL.encode(Data(body[headerLen..<headerLen + 3])) }
            return .v2(full)
        }
        if hasCatalogUuid, body.count >= 6, (0x10...0x1F).contains(body[0]), body[2] & 0x80 == 0 {
            return .v2(TinyBeaconV2(version: body[0], kindRaw: body[1], flags: Flags(rawValue: body[2]),
                                    caps: Caps(rawValue: UInt16(body[3]) | UInt16(body[4]) << 8), tools: body[5],
                                    session: 0, rssi1m: rssi1mUnknown, carrier: .short))
        }
        return nil
    }

    // MARK: name carrier (§2.5)

    /// "tn2.<12>[.<mine4>][.<display>]" — refuses a display tail on a person's device that is not social.
    func encodeName(mine: String? = nil, display: String? = nil, maxBytes: Int = 29) throws -> String {
        var s = Self.namePrefix + Base64URL.encode(try encodeHeader())
        if flags.contains(.mine) {
            guard let m = mine, m.count == 4, m.unicodeScalars.allSatisfy({ Base64URL.alphabet.unicodeScalars.contains($0) }) else { throw CodecError.mineTag }
            s += "." + m
        } else if mine != nil { throw CodecError.mineTag }
        if let name = display, !name.isEmpty {
            if kind.isPerson && !flags.contains(.social) { throw CodecError.displayNeedsSocial }
            if name.contains(".") { throw CodecError.displayHasDot }
            let budget = maxBytes - s.utf8.count - 1
            if budget > 0 {
                var tail = ""
                for ch in name { if (tail + String(ch)).utf8.count > budget { break }; tail.append(ch) }
                s += "." + tail
            }
        }
        return s
    }

    static func decodeName(_ name: String?) -> TinyBeaconV2? {
        guard let name, name.hasPrefix(namePrefix) else { return nil }
        let parts = name.dropFirst(namePrefix.count).split(separator: ".", omittingEmptySubsequences: false).map(String.init)
        guard let head = parts.first, head.count == 12, let bytes = Base64URL.decode(head), bytes.count == headerLen,
              var b = decodeHeader(bytes, carrier: .name) else { return nil }
        var i = 1
        if b.flags.contains(.mine) {
            guard parts.count >= 2, parts[1].count == 4 else { return nil }
            b.mine = parts[1]; i = 2
        }
        if parts.count > i { b.name = parts[i...].joined(separator: ".") }
        return b
    }

    /// Whichever carrier is present (§2.4 merge): mfg header wins, name/mine from the name carrier or the Complete Local Name.
    static func decode(mfg: Data?, name: String?, serviceUUIDs: [String]) -> Parsed? {
        let hasUuid = serviceUUIDs.contains { $0.uppercased() == TinyCatalog.serviceUUID }
        let fromMfg = decodeMfg(mfg, hasCatalogUuid: hasUuid)
        let fromName = decodeName(name)
        if let fromMfg, case .v2(var v) = fromMfg {
            // §2.5: a plain local name is a DISPLAY name only when the beacon
            // says social — otherwise the row stays "a tiny Sticky".
            let plain = v.flags.contains(.social) ? name : nil
            if let n = fromName { v.mine = n.mine ?? v.mine; v.name = n.name ?? plain } else { v.name = plain }
            return .v2(v)
        }
        if let n = fromName { return .v2(n) }
        if let fromMfg { return fromMfg }
        if hasUuid {
            // §2.6 witness-only: the peer advertises our Catalog service but no header
            // (macOS emitter). Zero header + .witness carrier → "a tiny peer" row whose
            // kind/tools arrive with the catalog read. Never a display name (§2.5).
            return .v2(TinyBeaconV2(version: version2, kindRaw: 0, flags: [.catalog], caps: [], tools: 0,
                                    session: 0, rssi1m: rssi1mUnknown, carrier: .witness))
        }
        return nil
    }

    // MARK: distance (§5)

    static func distanceMeters(rssiEma: Double, rssi1m: Int8, n: Double = 2.4) -> Double {
        let p1 = Double(rssi1m == rssi1mUnknown ? rssi1mDefault : rssi1m)
        return pow(10, (p1 - rssiEma) / (10 * n))
    }
    enum Band: String { case here, near, room, far }
    static func band(_ d: Double) -> Band { d < 1 ? .here : d < 3 ? .near : d < 8 ? .room : .far }
}

/// base64url without padding — same alphabet as tiny-beacon.ts.
enum Base64URL {
    static let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
    static func encode(_ d: Data) -> String {
        d.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
    }
    static func decode(_ s: String) -> Data? {
        guard s.unicodeScalars.allSatisfy({ alphabet.unicodeScalars.contains($0) }) else { return nil }
        var b = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
        while b.count % 4 != 0 { b += "=" }
        return Data(base64Encoded: b)
    }
}

/// v1 beacon (mirror of the parse in tinyai-id Bluetooth.swift) — the helper
/// only needs to recognise Nicla v1 packets, not the setup sheets.
struct TinyBeaconInfo: Equatable {
    let version: Int
    let provisioned: Bool
    enum Kind: Equatable { case vision, voice, sense, unknown }
    var kind: Kind { switch version { case 1: return .vision; case 2: return .voice; case 3: return .sense; default: return .unknown } }
    static func parse(_ mfg: Data?) -> TinyBeaconInfo? {
        guard let d = mfg, d.count >= 6, d[0] == 0xFF, d[1] == 0xFF, d[2] == 0x54, d[3] == 0x4E else { return nil }
        return TinyBeaconInfo(version: Int(d[4]), provisioned: d[5] != 0)
    }
}
