//
//  FilterDataProvider.swift
//  RebreakURLFilter — NEFilterDataProvider mit memory-mapped Hash-Liste.
//
//  Architektur:
//   - Container-App lädt `blocklist.bin` vom Server runter (sortierte 64-bit Hashes)
//   - File liegt in App-Group: group.org.rebreak.app/blocklist.bin
//   - Diese Extension memory-mapped die Datei und macht Binary-Search pro Flow
//   - Memory-Footprint: <1 MB (mmap working-set)
//
//  Privacy:
//   - Keine Klartext-Domains auf Disk (nur SHA-256/64-bit Hashes)
//   - User-Browsing-URL verlässt das Gerät nie
//

import NetworkExtension
import Foundation
import CryptoKit

/// Shared Log-Store via App-Group UserDefaults (für Container-App-Debug-Page).
enum SharedLogStore {
    static let appGroup = "group.org.rebreak.app"
    static let logKey = "url_filter_logs"
    static let maxEntries = 200

    static func append(_ message: String) {
        NSLog("REBREAK_URL_FILTER %@", message)
        guard let defaults = UserDefaults(suiteName: appGroup) else { return }
        let timestamp = ISO8601DateFormatter().string(from: Date())
        let entry = "[\(timestamp)] \(message)"
        var logs = defaults.stringArray(forKey: logKey) ?? []
        logs.append(entry)
        if logs.count > maxEntries { logs.removeFirst(logs.count - maxEntries) }
        defaults.set(logs, forKey: logKey)
    }
}

/// Domain-Hashing — IDENTISCH zu `server/utils/domainHash.ts`.
/// Server schickt: SHA-256(salt:domain).first(8) als big-endian UInt64.
enum DomainHasher {
    static func normalize(_ host: String) -> String {
        var h = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
        if h.hasPrefix("https://") { h = String(h.dropFirst(8)) }
        else if h.hasPrefix("http://") { h = String(h.dropFirst(7)) }
        if let slash = h.firstIndex(of: "/") { h = String(h[..<slash]) }
        if h.hasPrefix("www.") { h = String(h.dropFirst(4)) }
        return h
    }

    static func hash(_ host: String, salt: String = "") -> UInt64 {
        let normalized = normalize(host)
        let input = salt.isEmpty ? normalized : "\(salt):\(normalized)"
        guard let data = input.data(using: .utf8) else { return 0 }
        let digest = SHA256.hash(data: data)
        // First 8 bytes as big-endian UInt64 (matches Node's readBigUInt64BE)
        var result: UInt64 = 0
        for (i, byte) in digest.prefix(8).enumerated() {
            result |= UInt64(byte) << UInt64((7 - i) * 8)
        }
        return result
    }
}

/// Memory-mapped Binary-Hash-Liste. Lädt die Datei lazy beim ersten Zugriff,
/// reloaded bei DarwinNotification "rebreak.blocklist.updated".
final class HashListMmap {
    static let shared = HashListMmap()

    private static let appGroup = "group.org.rebreak.app"
    private static let filename = "blocklist.bin"

    private var data: Data?
    private var hashCount: Int = 0
    private var loadedMtime: Date?
    private var lastMtimeCheck: Date = .distantPast
    private let queue = DispatchQueue(label: "rebreak.hashlist.reload")

    private init() {
        load()
        observeUpdates()
    }

    private static var fileURL: URL? {
        FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier: appGroup)?
            .appendingPathComponent(filename)
    }

    private static func currentMtime() -> Date? {
        guard let url = fileURL,
              let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
              let mtime = attrs[.modificationDate] as? Date
        else { return nil }
        return mtime
    }

    /// Polled die mtime von blocklist.bin (max. 1×/sec) und reloaded den mmap
    /// wenn sich was geändert hat. Macht uns unabhängig von DarwinNotifications,
    /// die verloren gehen können wenn die Extension idle ist.
    private func refreshIfChanged() {
        let needsReload: Bool = queue.sync {
            let now = Date()
            if now.timeIntervalSince(lastMtimeCheck) < 1.0 { return false }
            lastMtimeCheck = now
            return loadedMtime != Self.currentMtime()
        }
        if needsReload { load() }
    }

    private func load() {
        queue.sync {
            guard let url = Self.fileURL,
                  FileManager.default.fileExists(atPath: url.path),
                  let mmapped = try? Data(contentsOf: url, options: .alwaysMapped)
            else {
                self.data = nil
                self.hashCount = 0
                self.loadedMtime = nil
                SharedLogStore.append("ℹ️ blocklist.bin not present — block-set ist leer")
                return
            }
            self.data = mmapped
            self.hashCount = mmapped.count / 8
            self.loadedMtime = Self.currentMtime()
            SharedLogStore.append("📂 blocklist.bin loaded: \(self.hashCount) hashes (\(mmapped.count) bytes)")
        }
    }

    private func observeUpdates() {
        // Container-App feuert DarwinNotification nach Sync. Apple's
        // CFNotificationCenterGetDarwinNotifyCenter erlaubt cross-process events
        // ohne shared state.
        let name = "rebreak.blocklist.updated" as CFString
        let center = CFNotificationCenterGetDarwinNotifyCenter()
        let observer = Unmanaged.passUnretained(self).toOpaque()
        CFNotificationCenterAddObserver(
            center,
            observer,
            { _, observer, _, _, _ in
                guard let observer = observer else { return }
                let me = Unmanaged<HashListMmap>.fromOpaque(observer).takeUnretainedValue()
                me.load()
            },
            name,
            nil,
            .deliverImmediately
        )
    }

    /// Binary-search auf sortierten 64-bit Hashes. O(log n).
    func contains(_ hash: UInt64) -> Bool {
        // Cheap mtime-check (rate-limited 1×/sec) — fängt verlorene
        // DarwinNotifications und stale-mmap nach atomic-replace.
        refreshIfChanged()
        return queue.sync {
            guard let data = self.data, self.hashCount > 0 else { return false }
            var lo = 0
            var hi = self.hashCount - 1
            while lo <= hi {
                let mid = (lo + hi) / 2
                let offset = mid * 8
                // Read big-endian UInt64 at offset
                var value: UInt64 = 0
                data.withUnsafeBytes { ptr in
                    let base = ptr.baseAddress!.advanced(by: offset)
                    for i in 0..<8 {
                        value = (value << 8) | UInt64(base.load(fromByteOffset: i, as: UInt8.self))
                    }
                }
                if value == hash { return true }
                if value < hash { lo = mid + 1 }
                else { hi = mid - 1 }
            }
            return false
        }
    }
}

class FilterDataProvider: NEFilterDataProvider {

    override func startFilter(completionHandler: @escaping (Error?) -> Void) {
        SharedLogStore.append("🚀 startFilter() called")
        // Trigger initial load
        _ = HashListMmap.shared
        completionHandler(nil)
    }

    override func stopFilter(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
        SharedLogStore.append("🛑 stopFilter() reason=\(reason.rawValue)")
        completionHandler()
    }

    override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict {
        guard let browserFlow = flow as? NEFilterBrowserFlow,
              let url = browserFlow.url,
              let host = url.host
        else {
            return .allow()
        }

        let normalizedHost = DomainHasher.normalize(host)
        let hashList = HashListMmap.shared

        // Subdomain-Match: für `evil.shop.bet365.com` testen wir
        //   - evil.shop.bet365.com
        //   - shop.bet365.com
        //   - bet365.com
        //   - com  (wird in der Praxis nie matchen — TLD steht nicht in der Liste)
        // Max 5 Iterationen (Cap zur Sicherheit).
        var current = normalizedHost
        var iter = 0
        while iter < 5 {
            let h = DomainHasher.hash(current)
            if hashList.contains(h) {
                SharedLogStore.append("🚫 BLOCKED: \(normalizedHost) (matched suffix: \(current))")
                return .drop()
            }
            // Strippen bis zum nächsten Punkt
            guard let dot = current.firstIndex(of: ".") else { break }
            current = String(current[current.index(after: dot)...])
            // Stoppen wenn kein Punkt mehr übrig (= TLD)
            if !current.contains(".") { break }
            iter += 1
        }

        return .allow()
    }
}
