// 客户端打点队列（14E）：App Group 文件队列，主 App 与键盘扩展两个进程共写。
// 设计对照 doc/frontend-design.html 9.2/9.3：
//   - 每事件一个 JSON 文件（跨进程并发写零冲突），本地上限 1000 条超出丢最旧
//   - 键盘扩展只写不发（无完全访问时无网络）；主 App 回到前台时批量 POST /v1/metrics/batch
//   - 失败保留 + 指数退避（60s 起、封顶 1h）；上报失败绝不影响功能
//   - 隐私底线：任何事件不携带识别文本与音频
import Foundation

/// 事件名白名单，对齐 server/pkg/protocol/dto.go MetricWhitelist（未知事件名服务端直接丢弃）。
public enum MetricEventName: String {
    case asrFirstPartialMs = "asr.first_partial_ms"
    case asrReleaseToCommitMs = "asr.release_to_commit_ms"
    case audioStartMs = "audio.start_ms"
    case wsConnectMs = "ws.connect_ms"
    case wsReconnect = "ws.reconnect"
    case asrSession = "asr.session"
    case injectFail = "inject.fail"
    case onboardingStep = "onboarding.step"
    case loginSuccess = "login.success"
    case purchaseSuccess = "purchase.success"
    case kbEnabled = "kb.enabled"
}

public final class MetricsQueue {
    public static let shared = MetricsQueue()

    /// 对齐 protocol.MetricsMaxBatch
    private static let maxBatch = 100
    /// 本地队列上限（9.2：超出丢最旧）
    private static let maxQueued = 1000

    private enum Key {
        static let deviceID = "metrics.device_id"
        static let failCount = "metrics.fail_count"
        static let nextAttempt = "metrics.next_attempt"
    }

    private let io = DispatchQueue(label: "app.dudu.metrics", qos: .utility)
    private let fm = FileManager.default
    private let defaults = UserDefaults(suiteName: AccountStore.appGroupID) ?? .standard

    /// 近似队列计数（仅 io 队列访问）：track 路径不再每事件全目录扫描；
    /// 首次写入时从磁盘 lazy 校准一次，之后递增。trim 只在 flush 路径做。
    /// 仅本进程视角的近似值（键盘扩展是另一进程，各自计数）—— 真正的精确
    /// 上限收口在 flush() 的全量扫描里兜底，这里只为避免 track 的 O(n) 热路径。
    private var approxCount: Int?

    /// 事件落盘目录：App Group 容器 /metrics；无 App Group（无签名本地调试）退回 caches 保证不崩
    private var dir: URL? {
        if let base = fm.containerURL(forSecurityApplicationGroupIdentifier: AccountStore.appGroupID) {
            return base.appendingPathComponent("metrics", isDirectory: true)
        }
        return fm.urls(for: .cachesDirectory, in: .userDomainMask).first?
            .appendingPathComponent("metrics", isDirectory: true)
    }

    /// 匿名设备标识（首次访问生成，与账号无关）
    public var deviceID: String {
        if let id = defaults.string(forKey: Key.deviceID) { return id }
        let id = "d-" + UUID().uuidString.lowercased()
        defaults.set(id, forKey: Key.deviceID)
        return id
    }

    // MARK: - 写入（主 App 与键盘扩展均可调）

    public func track(_ event: MetricEventName, props: [String: JSONValue]? = nil) {
        let ev = MetricEvent(event: event.rawValue, props: props,
                             clientTs: Int64(Date().timeIntervalSince1970 * 1000))
        io.async { [self] in
            guard let dir else { return }
            try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
            // 文件名前缀时间戳：flush 时按名排序即按时间序
            let name = String(format: "%017.6f", Date().timeIntervalSince1970)
                + "-" + UUID().uuidString + ".json"
            if let data = try? JSONEncoder().encode(ev),
               (try? data.write(to: dir.appendingPathComponent(name))) != nil {
                // 近似计数 +1（首次 lazy 从磁盘校准）。track 路径不再全目录扫描；
                // 实际 trim 推迟到 flush()，那里的全量扫描会精确收口到 maxQueued。
                let base = approxCount ?? diskCount(dir)
                approxCount = base + 1
            }
        }
    }

    /// 当前队列文件数（全目录扫描，仅 flush / lazy 校准用，不在 track 热路径）
    private func diskCount(_ dir: URL) -> Int {
        (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))?
            .filter { $0.pathExtension == "json" }.count ?? 0
    }

    /// flush 入口同步 trim（io 队列）：超 maxQueued 丢最旧，并校准 approxCount
    private func trimIfNeeded(_ dir: URL) {
        guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return }
        let jsons = files.filter { $0.pathExtension == "json" }
        if jsons.count > Self.maxQueued {
            let sorted = jsons.sorted { $0.lastPathComponent < $1.lastPathComponent }
            for f in sorted.prefix(sorted.count - Self.maxQueued) {
                try? fm.removeItem(at: f)
            }
            approxCount = Self.maxQueued
        } else {
            approxCount = jsons.count
        }
    }

    // MARK: - 上报（仅主 App 前台调用；键盘扩展只写队列）

    public func flush() async {
        // 退避冷却期内不发
        guard Date().timeIntervalSince1970 >= defaults.double(forKey: Key.nextAttempt),
              let dir else { return }
        // 上限收口推迟到此（前台 flush 路径）：在 io 队列同步 trim + 校准 approxCount，
        // track 热路径不再每事件全目录扫描
        io.sync { trimIfNeeded(dir) }
        guard var files = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))?
            .filter({ $0.pathExtension == "json" })
            .sorted(by: { $0.lastPathComponent < $1.lastPathComponent }),
            !files.isEmpty else { return }

        let decoder = JSONDecoder()
        while !files.isEmpty {
            let batchFiles = Array(files.prefix(Self.maxBatch))
            files.removeFirst(batchFiles.count)
            var events: [MetricEvent] = []
            for f in batchFiles {
                if let data = try? Data(contentsOf: f),
                   let ev = try? decoder.decode(MetricEvent.self, from: data) {
                    events.append(ev)
                }
            }
            if events.isEmpty {
                batchFiles.forEach { try? fm.removeItem(at: $0) } // 全是坏文件，直接清
                continue
            }
            let batch = MetricsBatchRequest(deviceID: deviceID,
                                            platform: DeviceInfo.platform,
                                            appVersion: DeviceInfo.appVersion,
                                            osVersion: DeviceInfo.osVersion,
                                            events: events)
            do {
                try await Api.client.postMetrics(batch)
                batchFiles.forEach { try? fm.removeItem(at: $0) }
                defaults.set(0, forKey: Key.failCount)
                defaults.set(0.0, forKey: Key.nextAttempt)
            } catch {
                // 失败保留文件，指数退避后下次前台再试
                let fails = defaults.integer(forKey: Key.failCount) + 1
                defaults.set(fails, forKey: Key.failCount)
                let delay = min(3600.0, 60.0 * pow(2.0, Double(fails - 1)))
                defaults.set(Date().timeIntervalSince1970 + delay, forKey: Key.nextAttempt)
                return
            }
        }
    }
}
