/*
 HashList — Swift-Port von `android/.../filter/HashList.kt`.

 Memory-mapped binäre Hash-Liste — sortierte 64-bit big-endian UInt64s.

 Datei-Format identisch zu Android und Server: jeder Hash 8 Bytes, sortiert.
 Reader macht Binary-Search → O(log n) Lookup (~18 Vergleiche bei 208k Hashes).

 Reload-Strategie: bei jedem `load()` wird die Datei neu gemmapt. Aufrufer muss
 `load()` triggern (z.B. nach `syncBlocklist` via Darwin-Notification).

 Speicher: `mmap` lädt nur angefragte Pages — die ganze 1,6-MB-Datei wird NIE
 in den Heap geladen. Working-Set bleibt im KB-Bereich. Kritisch für das
 enge Memory-Limit von Network-Extensions.

 Thread-safety: ein `os_unfair_lock` schützt das Snapshot-Swapping. Lookups
 lesen ein konsistentes Snapshot (Pointer + count) unter dem Lock — der
 mmap-Bereich selbst ist read-only und damit nebenläufig lesbar.
*/

import Foundation
import os

final class HashList {

  /// Ein konsistenter Snapshot des aktuell gemmapten Files.
  private struct Snapshot {
    let base: UnsafeRawPointer?
    let byteCount: Int
    let count: Int  // Anzahl 8-Byte-Hashes
  }

  private let fileURL: URL
  private var snapshot = Snapshot(base: nil, byteCount: 0, count: 0)
  private var lock = os_unfair_lock()

  init(fileURL: URL) {
    self.fileURL = fileURL
  }

  deinit {
    unmapCurrent()
  }

  /// Lädt `blocklist.bin` neu (mmap). Idempotent — kann zur Laufzeit mehrfach
  /// aufgerufen werden; die alte Map wird per `munmap` freigegeben.
  func load() {
    os_unfair_lock_lock(&lock)
    defer { os_unfair_lock_unlock(&lock) }

    // Alte Map freigeben bevor wir neu mappen.
    unmapCurrentLocked()

    guard FileManager.default.fileExists(atPath: fileURL.path) else {
      snapshot = Snapshot(base: nil, byteCount: 0, count: 0)
      return
    }

    let fd = open(fileURL.path, O_RDONLY)
    guard fd >= 0 else {
      snapshot = Snapshot(base: nil, byteCount: 0, count: 0)
      return
    }
    defer { close(fd) }  // mmap überlebt den geschlossenen fd.

    var st = stat()
    guard fstat(fd, &st) == 0 else {
      snapshot = Snapshot(base: nil, byteCount: 0, count: 0)
      return
    }
    let size = Int(st.st_size)
    // Datei muss ein Vielfaches von 8 Bytes sein, sonst ist sie korrupt.
    guard size > 0, size % 8 == 0 else {
      snapshot = Snapshot(base: nil, byteCount: 0, count: 0)
      return
    }

    guard let mapped = mmap(nil, size, PROT_READ, MAP_PRIVATE, fd, 0),
          mapped != MAP_FAILED else {
      snapshot = Snapshot(base: nil, byteCount: 0, count: 0)
      return
    }

    snapshot = Snapshot(
      base: UnsafeRawPointer(mapped),
      byteCount: size,
      count: size / 8
    )
  }

  /// Aktuelle Anzahl der Hashes (zum Logging).
  func count() -> Int {
    os_unfair_lock_lock(&lock)
    defer { os_unfair_lock_unlock(&lock) }
    return snapshot.count
  }

  /// True wenn die Datei aktuell als Blocklist gemmapt ist.
  func isLoaded() -> Bool {
    os_unfair_lock_lock(&lock)
    defer { os_unfair_lock_unlock(&lock) }
    return snapshot.base != nil
  }

  /// Binary-Search auf den sortierten 64-bit-Hashes. O(log n).
  ///
  /// Die Datei ist als UNSIGNED 64-bit big-endian sortiert; Swifts `UInt64`-
  /// Vergleich ist nativ unsigned, daher entfällt das `compareUnsigned` aus dem
  /// Kotlin-Code. Jeder 8-Byte-Block wird big-endian aus dem mmap gelesen.
  func contains(_ hash: UInt64) -> Bool {
    os_unfair_lock_lock(&lock)
    let snap = snapshot
    os_unfair_lock_unlock(&lock)

    guard let base = snap.base, snap.count > 0 else { return false }

    var lo = 0
    var hi = snap.count - 1
    while lo <= hi {
      let mid = (lo + hi) >> 1
      let value = readBigEndianUInt64(base, offset: mid * 8)
      if value == hash {
        return true
      } else if value < hash {
        lo = mid + 1
      } else {
        hi = mid - 1
      }
    }
    return false
  }

  /// Subdomain-Match (max 5 Iterationen — identisch zu Android & Server).
  /// Für `evil.shop.bet365.com` wird der Reihe nach getestet:
  ///   evil.shop.bet365.com → shop.bet365.com → bet365.com
  /// (eine TLD ohne Punkt wird nie getestet → kein false-positive auf `com`).
  func matchesAnySuffix(_ host: String) -> Bool {
    var current = DomainHasher.normalize(host)
    var iter = 0
    while iter < 5 {
      if contains(DomainHasher.hash(current)) {
        return true
      }
      guard let dot = current.firstIndex(of: ".") else { return false }
      current = String(current[current.index(after: dot)...])
      if !current.contains(".") { return false }
      iter += 1
    }
    return false
  }

  // ─── Private ──────────────────────────────────────────────────────────────

  /// Liest 8 Bytes ab `offset` als big-endian UInt64 aus dem mmap-Bereich.
  /// `loadUnaligned` deckt den Fall ab, dass `base + offset` nicht 8-aligned
  /// ist (mmap'd Pages sind page-aligned, Offsets sind 8er-Vielfache → in der
  /// Praxis aligned, aber `loadUnaligned` ist defensiv korrekt).
  private func readBigEndianUInt64(_ base: UnsafeRawPointer, offset: Int) -> UInt64 {
    let raw = base.load(fromByteOffset: offset, as: UInt64.self)
    return UInt64(bigEndian: raw)
  }

  private func unmapCurrent() {
    os_unfair_lock_lock(&lock)
    unmapCurrentLocked()
    snapshot = Snapshot(base: nil, byteCount: 0, count: 0)
    os_unfair_lock_unlock(&lock)
  }

  /// Muss unter gehaltenem `lock` aufgerufen werden.
  private func unmapCurrentLocked() {
    if let base = snapshot.base, snapshot.byteCount > 0 {
      munmap(UnsafeMutableRawPointer(mutating: base), snapshot.byteCount)
    }
  }
}
