//
//  MetricNudge.swift
//
//  Self-hosted analytics SDK. One file, no dependencies, iOS 15+.
//  Events buffer in memory and on disk and flush in batches to your MetricNudge
//  server. Never crashes, never blocks the main thread, and does nothing
//  until `configure` is called.
//
//  Setup:
//    1. Set `MetricNudge.defaultEndpoint` below to your server (or pass `endpoint:`).
//    2. At launch, after RevenueCat:
//         MetricNudge.configure(app: "myapp", key: "tl_pk_…", userId: Purchases.shared.appUserID)
//
//  Migrating from ActivationPal: delete ActivationPal.swift and add this
//  file. The `ActivationPal` typealias at the bottom keeps existing call
//  sites compiling unchanged, and existing installs keep their ActivationPal
//  device id (no duplicate first_open), so history carries over.
//

import Foundation
import StoreKit
import UIKit
#if canImport(AdServices)
import AdServices
#endif

public enum MetricNudge {

    /// Your MetricNudge server's ingest URL. Used when `configure` gets no `endpoint`.
    public static var defaultEndpoint = URL(string: "https://YOUR-TALLY-HOST/api/ingest")!

    // MARK: Setup

    public static func configure(app: String, key: String, endpoint: URL? = nil, userId: String? = nil) {
        Engine.shared.configure(app: app, key: key, endpoint: endpoint ?? defaultEndpoint, userId: userId)
    }

    /// Joins this install to revenue events. Pass the RevenueCat app user id.
    public static func setUserId(_ id: String?) {
        Engine.shared.setUserId(id)
    }

    /// Sends everything buffered now (e.g. before a crash-prone operation).
    public static func flush() {
        Engine.shared.flushNow()
    }

    // MARK: Events

    /// Where an event actually happened when that isn't this process — e.g. a
    /// Watch app relaying through the phone. Lands in `props.origin`.
    public enum Origin: String {
        case phone, watch, widget, vision, mac
    }

    /// Custom event. snake_case, past tense. Prop values: String, Bool, Int, Double.
    public static func track(_ name: String, _ props: [String: Any]? = nil, from origin: Origin? = nil) {
        Engine.shared.track(name, props: props, origin: origin)
    }

    public static func trackFromWatch(_ name: String, _ props: [String: Any]? = nil) {
        track(name, props, from: .watch)
    }

    // MARK: Fixed events — the funnel and paywall cards match on these names

    /// One call per onboarding screen, in order, index from 0.
    public static func onboardingStep(_ index: Int, id: String, answer: String? = nil) {
        var props: [String: Any] = ["index": index, "id": id]
        if let answer { props["answer"] = answer }
        track("onboarding_step", props)
    }

    public static func onboardingCompleted() { track("onboarding_completed") }

    /// `placement` is where the paywall appeared ("onboarding", "settings", …).
    public static func paywallShown(_ placement: String) { track("paywall_shown", ["placement": placement]) }

    /// Call only when the user taps a plan — not for the plan preselected on appear.
    public static func paywallPlanSelected(_ plan: String, placement: String? = nil) {
        track("paywall_plan_selected", paywallProps(["plan": plan], placement))
    }

    /// `placement` credits the sale to the paywall that made it; `hasTrial` marks trial starts.
    public static func paywallPurchased(_ plan: String, placement: String? = nil, hasTrial: Bool? = nil, productId: String? = nil) {
        var props: [String: Any] = ["plan": plan]
        if let hasTrial { props["has_trial"] = hasTrial }
        if let productId { props["product_id"] = productId }
        track("paywall_purchased", paywallProps(props, placement))
    }

    public static func paywallDismissed(placement: String? = nil) {
        track("paywall_dismissed", paywallProps([:], placement))
    }

    private static func paywallProps(_ props: [String: Any], _ placement: String?) -> [String: Any] {
        var p = props
        if let placement { p["placement"] = placement }
        return p
    }
}

// MARK: - Engine

// All mutable state lives on `queue`, hence @unchecked Sendable.
private final class Engine: @unchecked Sendable {

    static let shared = Engine()

    private enum Keys {
        static let deviceId = "tally.device_id"
        static let firstOpenSent = "tally.first_open_sent"
        static let attributionSent = "tally.attribution_sent"
        // Written by the ActivationPal SDK. Adopted once so existing installs keep
        // their identity instead of showing up as brand-new installs.
        static let legacyDeviceId = "ap_device_id"
        static let legacyFirstOpen = "ap_first_open_tracked"
        static let legacyAttribution = "ap_attribution_tracked"
    }

    private let maxBuffered = 1000
    private let flushAt = 20
    private let maxBatch = 100
    private let flushEvery: TimeInterval = 10
    private let newSessionAfter: TimeInterval = 30 * 60

    private let queue = DispatchQueue(label: "tally.sdk", qos: .utility)
    private var ready = false
    private var endpoint: URL!
    private var appId = ""
    private var apiKey = ""
    private var userId: String?
    private var deviceId = ""
    private var sessionId = ""
    private var context: [String: String] = [:]
    private var buffer: [[String: Any]] = []
    private var inFlight = false
    private var failures = 0
    private var nextAttempt = Date.distantPast
    private var backgroundedAt: Date?
    private var timer: DispatchSourceTimer?
    private var observers: [NSObjectProtocol] = []

    private let iso: ISO8601DateFormatter = {
        let f = ISO8601DateFormatter()
        f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        return f
    }()

    private lazy var storeURL: URL? = {
        guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
            .appendingPathComponent("tally", isDirectory: true) else { return nil }
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        return dir.appendingPathComponent("pending.json")
    }()

    // MARK: Configure

    func configure(app: String, key: String, endpoint: URL, userId: String?) {
        // Read UIKit / Bundle state on the calling thread before hopping queues.
        let info = Bundle.main.infoDictionary ?? [:]
        let base: [String: String?] = [
            "platform": "ios",
            "app_version": info["CFBundleShortVersionString"] as? String,
            "build": info["CFBundleVersion"] as? String,
            "os_version": Self.osVersion(),
            "device_model": Self.hardwareModel(),
            "locale": Locale.current.identifier,
            "app_language": Bundle.main.preferredLocalizations.first,
            "timezone": TimeZone.current.identifier,
            "installed_at": Self.installDate().map { iso.string(from: $0) },
            "sdk_version": "tally-1",
            "env": Self.environment(),
        ]

        queue.async {
            if self.ready {
                if let userId { self.userId = userId }
                return
            }
            self.ready = true
            self.appId = app
            self.apiKey = key
            self.endpoint = endpoint
            self.userId = userId
            self.context = base.compactMapValues { $0 }

            let defaults = UserDefaults.standard
            self.adoptActivationPalState(defaults)
            if let id = defaults.string(forKey: Keys.deviceId) {
                self.deviceId = id
            } else {
                self.deviceId = UUID().uuidString
                defaults.set(self.deviceId, forKey: Keys.deviceId)
            }

            self.restore()

            // App Store country — what prices are set in, which can differ from IP geo.
            // Unwrap once: a weak capture is a `var`, and reading that var from the
            // queue closure below is a cross-thread capture Swift warns about.
            Task { [weak self] in
                guard let code = await Storefront.current?.countryCode, let self else { return }
                self.queue.async { self.context["storefront"] = code }
            }

            if !defaults.bool(forKey: Keys.firstOpenSent) {
                self.enqueue("first_open", props: [:])
                defaults.set(true, forKey: Keys.firstOpenSent)
            }
            // The token can fail to mint transiently; keep trying on later launches.
            if !defaults.bool(forKey: Keys.attributionSent), self.captureAttribution() {
                defaults.set(true, forKey: Keys.attributionSent)
            }
            self.beginSession()
            self.observeLifecycle()
            self.startTimer()
            self.send()
        }
    }

    func setUserId(_ id: String?) {
        queue.async { self.userId = id }
    }

    /// One-time migration from the ActivationPal SDK: same device id, no second
    /// first_open, no second attribution token, and its unsent events come along.
    /// Must run on `queue`, before the device id is read.
    private func adoptActivationPalState(_ defaults: UserDefaults) {
        if defaults.string(forKey: Keys.deviceId) == nil, let legacy = defaults.string(forKey: Keys.legacyDeviceId) {
            defaults.set(legacy, forKey: Keys.deviceId)
        }
        if defaults.bool(forKey: Keys.legacyFirstOpen) { defaults.set(true, forKey: Keys.firstOpenSent) }
        if defaults.bool(forKey: Keys.legacyAttribution) { defaults.set(true, forKey: Keys.attributionSent) }

        guard let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
        let legacyQueue = support.appendingPathComponent("activationpal/queue.json")
        guard let data = try? Data(contentsOf: legacyQueue) else { return }
        if let pending = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
            buffer.append(contentsOf: pending.map { event in
                var e = event
                if e["event_id"] == nil { e["event_id"] = UUID().uuidString }
                return e
            })
        }
        try? FileManager.default.removeItem(at: legacyQueue)
    }

    func flushNow() {
        queue.async {
            self.nextAttempt = .distantPast
            self.send()
        }
    }

    // MARK: Track

    func track(_ name: String, props: [String: Any]?, origin: MetricNudge.Origin?) {
        var clean = Self.clean(props)
        if let origin { clean["origin"] = origin.rawValue }
        let ts = iso.string(from: Date())
        queue.async {
            guard self.ready else { return }
            self.enqueue(name, props: clean, ts: ts)
        }
    }

    /// Must run on `queue`.
    private func enqueue(_ name: String, props: [String: Any], ts: String? = nil) {
        var event: [String: Any] = [
            "event_id": UUID().uuidString,
            "name": name,
            "ts": ts ?? iso.string(from: Date()),
            "device_id": deviceId,
            "session_id": sessionId,
        ]
        if let userId { event["user_id"] = userId }
        if !props.isEmpty { event["props"] = props }
        event.merge(context) { current, _ in current }

        buffer.append(event)
        if buffer.count > maxBuffered {
            // The attribution token can't be re-minted once marked sent, so drop ordinary events first.
            let drop = buffer.firstIndex { ($0["name"] as? String) != "asa_token" } ?? 0
            buffer.remove(at: drop)
        }
        persist()
        if buffer.count >= flushAt { send() }
    }

    /// Apple Ads attribution. Only the device can mint this token; the server
    /// exchanges it with Apple (valid 24h). Must run on `queue`.
    private func captureAttribution() -> Bool {
        #if canImport(AdServices)
        if #available(iOS 14.3, *), let token = try? AAAttribution.attributionToken() {
            enqueue("asa_token", props: ["token": token])
            return true
        }
        #endif
        return false
    }

    // MARK: Sessions & lifecycle

    private func beginSession() {
        sessionId = UUID().uuidString
        enqueue("app_open", props: [:])
    }

    private func observeLifecycle() {
        let center = NotificationCenter.default
        observers.append(center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in
            guard let self else { return }
            // Ask for a little background time so the last batch actually leaves the device.
            var task: UIBackgroundTaskIdentifier = .invalid
            task = UIApplication.shared.beginBackgroundTask(withName: "tally.flush") {
                UIApplication.shared.endBackgroundTask(task)
            }
            self.queue.async {
                self.backgroundedAt = Date()
                self.nextAttempt = .distantPast
                self.send {
                    DispatchQueue.main.async { UIApplication.shared.endBackgroundTask(task) }
                }
            }
        })
        observers.append(center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in
            guard let self else { return }
            self.queue.async {
                if let since = self.backgroundedAt, Date().timeIntervalSince(since) > self.newSessionAfter {
                    self.beginSession()
                }
                self.backgroundedAt = nil
            }
        })
    }

    private func startTimer() {
        let t = DispatchSource.makeTimerSource(queue: queue)
        t.schedule(deadline: .now() + flushEvery, repeating: flushEvery)
        t.setEventHandler { [weak self] in self?.send() }
        t.resume()
        timer = t
    }

    // MARK: Network

    /// Must run on `queue`. `done` is called once this attempt finishes (or is skipped).
    private func send(done: (() -> Void)? = nil) {
        guard ready, !inFlight, !buffer.isEmpty, Date() >= nextAttempt else { done?(); return }

        let batch = Array(buffer.prefix(maxBatch))
        guard let body = try? JSONSerialization.data(withJSONObject: ["app": appId, "sent_at": iso.string(from: Date()), "events": batch]) else {
            // Something unserializable slipped in; drop it rather than wedge the queue forever.
            buffer.removeFirst(min(batch.count, buffer.count))
            persist()
            done?()
            return
        }

        inFlight = true
        var request = URLRequest(url: endpoint, timeoutInterval: 20)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue(apiKey, forHTTPHeaderField: "x-app-key")
        request.httpBody = body

        URLSession.shared.dataTask(with: request) { [weak self] _, response, _ in
            guard let self else { done?(); return }
            let status = (response as? HTTPURLResponse)?.statusCode ?? 0
            self.queue.async {
                self.inFlight = false
                if status == 200 {
                    self.buffer.removeFirst(min(batch.count, self.buffer.count))
                    self.failures = 0
                    self.persist()
                    #if DEBUG
                    print("[MetricNudge] sent \(batch.count) event(s)")
                    #endif
                    // More waiting? Keep draining.
                    if self.buffer.count >= self.flushAt { self.send() }
                } else {
                    // Back off: 10s, 20s, 40s … capped at 5 minutes.
                    self.failures += 1
                    self.nextAttempt = Date().addingTimeInterval(min(300, 10 * pow(2, Double(self.failures - 1))))
                    #if DEBUG
                    print("[MetricNudge] send failed (HTTP \(status)); holding \(self.buffer.count) event(s)")
                    #endif
                }
                done?()
            }
        }.resume()
    }

    // MARK: Disk

    private func persist() {
        guard let url = storeURL, let data = try? JSONSerialization.data(withJSONObject: buffer) else { return }
        try? data.write(to: url, options: .atomic)
    }

    private func restore() {
        guard let url = storeURL,
              let data = try? Data(contentsOf: url),
              let saved = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
              !saved.isEmpty else { return }
        buffer = Array((saved + buffer).suffix(maxBuffered))
        #if DEBUG
        print("[MetricNudge] restored \(saved.count) pending event(s)")
        #endif
    }

    // MARK: Helpers

    private static func clean(_ props: [String: Any]?) -> [String: Any] {
        guard let props else { return [:] }
        var out: [String: Any] = [:]
        for (k, v) in props {
            switch v {
            case let v as String: out[k] = v
            case let v as Bool: out[k] = v
            case let v as Int: out[k] = v
            case let v as Double where v.isFinite: out[k] = v
            case let v as Float where v.isFinite: out[k] = Double(v)
            default: continue // anything else is silently dropped
            }
        }
        return out
    }

    private static func osVersion() -> String {
        Thread.isMainThread ? UIDevice.current.systemVersion : DispatchQueue.main.sync { UIDevice.current.systemVersion }
    }

    /// "iPhone15,4" — or "arm64"/"x86_64" in the Simulator.
    private static func hardwareModel() -> String {
        var info = utsname()
        uname(&info)
        return withUnsafeBytes(of: &info.machine) { String(decoding: $0.prefix { $0 != 0 }, as: UTF8.self) }
    }

    /// debug (DEBUG builds), testflight (sandbox receipt), release (App Store).
    private static func environment() -> String {
        #if DEBUG
        return "debug"
        #else
        return Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" ? "testflight" : "release"
        #endif
    }

    /// Creation date of Documents ≈ install date. Tells new installs apart from
    /// existing users who updated into the first SDK-enabled version.
    private static func installDate() -> Date? {
        guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first,
              let attrs = try? FileManager.default.attributesOfItem(atPath: docs.path) else { return nil }
        return attrs[.creationDate] as? Date
    }
}

/// Drop-in for apps moving from ActivationPal: `ActivationPal.track(...)` etc. keep compiling.
public typealias ActivationPal = MetricNudge

/// Drop-in for apps that already use the earlier name: `Tally.track(...)` etc. keep compiling.
public typealias Tally = MetricNudge
