//
// Copyright (c) Double Symmetry GmbH
// Commercial use requires a license. See https://rntp.dev/pricing
//

import XCTest
@testable import PlayerCore

/// Tests that the cache proxy serves HLS playlists by rewriting their child
/// URIs back through the proxy (so segments are fetched and cached per-URL),
/// and serves VOD manifests from cache when the origin is offline.
final class HLSCacheProxyTests: XCTestCase {
  private var cacheDir: URL!
  private var cache: AudioCache!
  private var coordinator: DownloadCoordinator!
  private var proxy: CacheProxyServer!

  override func setUp() {
    super.setUp()
    cacheDir = FileManager.default.temporaryDirectory
      .appendingPathComponent("HLSCacheProxyTests-\(UUID().uuidString)", isDirectory: true)
    cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
    coordinator = DownloadCoordinator(cache: cache)
    proxy = try! CacheProxyServer(coordinator: coordinator, cache: cache)
    proxy.start()
  }

  override func tearDown() {
    proxy.stop()
    coordinator.cancelAll()
    proxy = nil
    coordinator = nil
    try? FileManager.default.removeItem(at: cacheDir)
    super.tearDown()
  }

  // MARK: - Helpers

  private func fetch(_ url: URL, timeout: TimeInterval = 10.0) throws -> (Data, HTTPURLResponse) {
    let expectation = self.expectation(description: "fetch \(url)")
    var resultData: Data?
    var resultResponse: HTTPURLResponse?
    var resultError: Error?

    let task = URLSession.shared.dataTask(with: url) { data, response, error in
      resultData = data
      resultResponse = response as? HTTPURLResponse
      resultError = error
      expectation.fulfill()
    }
    task.resume()
    wait(for: [expectation], timeout: timeout)

    if let error = resultError { throw error }
    return (resultData ?? Data(), resultResponse!)
  }

  /// A VOD media playlist with two relative segment URIs.
  private func vodMediaPlaylist() -> String {
    return """
    #EXTM3U
    #EXT-X-VERSION:3
    #EXT-X-TARGETDURATION:1
    #EXT-X-MEDIA-SEQUENCE:0
    #EXTINF:1.0,
    seg0.aac
    #EXTINF:1.0,
    seg1.aac
    #EXT-X-ENDLIST
    """
  }

  private func makeHLSServer(seg0: Data, seg1: Data) throws -> LocalAudioServer {
    return try LocalAudioServer(routes: [
      "/index.m3u8": .init(data: Data(vodMediaPlaylist().utf8), contentType: "application/vnd.apple.mpegurl"),
      "/seg0.aac": .init(data: seg0, contentType: "audio/aac"),
      "/seg1.aac": .init(data: seg1, contentType: "audio/aac"),
    ])
  }

  /// The proxied (rewritten) child-URI lines in a served manifest — the bare
  /// lines that now point back at this proxy.
  private func proxiedLines(in body: String) -> [String] {
    return body
      .components(separatedBy: "\n")
      .filter { $0.hasPrefix("http://localhost:\(proxy.port)") }
  }

  // MARK: - 1. Manifest segment URIs are rewritten to proxy URLs

  func testProxyRewritesManifestSegmentURIs() throws {
    let seg = Data(repeating: 0xAA, count: 1024)
    let server = try makeHLSServer(seg0: seg, seg1: seg)
    server.start()
    defer { server.stop() }

    let manifestURL = server.url(forPath: "/index.m3u8")
    let proxyURL = proxy.proxyURL(for: manifestURL, headers: nil)

    let (data, response) = try fetch(proxyURL)
    let body = String(data: data, encoding: .utf8) ?? ""

    XCTAssertEqual(response.statusCode, 200)

    // Served as an HLS playlist so AVPlayer parses it.
    let ct = (response.value(forHTTPHeaderField: "Content-Type") ?? "").lowercased()
    XCTAssertTrue(ct.contains("mpegurl"), "manifest must be served with an HLS content type, got '\(ct)'")

    // The raw relative segment lines must be gone, replaced by proxy URLs that
    // encode the resolved absolute upstream segment URLs.
    XCTAssertFalse(body.contains("\nseg0.aac"), "raw relative 'seg0.aac' must be rewritten")
    XCTAssertFalse(body.contains("\nseg1.aac"), "raw relative 'seg1.aac' must be rewritten")
    XCTAssertEqual(proxiedLines(in: body).count, 2, "both segments must be rewritten to proxy URLs")

    // Structure preserved.
    XCTAssertTrue(body.contains("#EXT-X-ENDLIST"))
    XCTAssertTrue(body.contains("#EXTINF:1.0,"))
  }

  // MARK: - 2. A rewritten segment URL is fetchable through the proxy

  func testProxiedSegmentIsFetchableThroughProxy() throws {
    let seg0 = Data(repeating: 0xA0, count: 1500)
    let seg1 = Data(repeating: 0xB1, count: 1500)
    let server = try makeHLSServer(seg0: seg0, seg1: seg1)
    server.start()
    defer { server.stop() }

    let manifestURL = server.url(forPath: "/index.m3u8")
    let (manifestData, _) = try fetch(proxy.proxyURL(for: manifestURL, headers: nil))
    let body = String(data: manifestData, encoding: .utf8) ?? ""

    // The first proxied line is seg0 (order is preserved by the rewriter).
    let firstSegLine = try XCTUnwrap(proxiedLines(in: body).first,
                                     "manifest must contain a proxied segment URL")
    let segProxyURL = try XCTUnwrap(URL(string: firstSegLine))

    let (segData, segResponse) = try fetch(segProxyURL)
    XCTAssertEqual(segResponse.statusCode, 200)
    XCTAssertEqual(segData, seg0, "fetching the proxied segment URL must return seg0's bytes")

    // And it should now be cached by its own absolute upstream URL.
    let seg0Key = cache.cacheKey(for: server.url(forPath: "/seg0.aac"))
    Thread.sleep(forTimeInterval: 0.3)
    XCTAssertTrue(cache.isFullyCached(key: seg0Key), "segment must be cached per-URL")
  }

  // MARK: - 3. VOD manifest is served from cache when origin is offline

  func testVODManifestServedFromCacheOffline() throws {
    let seg = Data(repeating: 0xCC, count: 1024)
    let server = try makeHLSServer(seg0: seg, seg1: seg)
    server.start()

    let manifestURL = server.url(forPath: "/index.m3u8")
    let proxyURL = proxy.proxyURL(for: manifestURL, headers: nil)

    // First fetch — caches the (VOD) manifest.
    _ = try fetch(proxyURL)
    Thread.sleep(forTimeInterval: 0.3)

    // Origin goes away.
    server.stop()

    // Second fetch must still succeed and still be rewritten — served from cache.
    let (data, response) = try fetch(proxyURL)
    let body = String(data: data, encoding: .utf8) ?? ""

    XCTAssertEqual(response.statusCode, 200, "VOD manifest must be served from cache when offline")
    XCTAssertEqual(proxiedLines(in: body).count, 2, "cached manifest must still be rewritten")
  }

  // MARK: - 4. Live (non-VOD) manifest is never cached and is re-fetched fresh

  /// A live media playlist: no `#EXT-X-ENDLIST` and no `PLAYLIST-TYPE:VOD`, so
  /// `HLSManifestRewriter.isVOD` is false and the proxy must never durably cache
  /// it. Each request must hit the origin again (so a moving live window is
  /// reflected) and must still be rewritten through the proxy.
  func testLiveManifestNotCachedAndRefetched() throws {
    let livePlaylist = """
    #EXTM3U
    #EXT-X-VERSION:3
    #EXT-X-TARGETDURATION:1
    #EXT-X-MEDIA-SEQUENCE:0
    #EXTINF:1.0,
    seg0.aac
    #EXTINF:1.0,
    seg1.aac
    """
    let seg = Data(repeating: 0xDD, count: 256)
    let server = try LocalAudioServer(routes: [
      "/live.m3u8": .init(data: Data(livePlaylist.utf8), contentType: "application/vnd.apple.mpegurl"),
      "/seg0.aac": .init(data: seg, contentType: "audio/aac"),
      "/seg1.aac": .init(data: seg, contentType: "audio/aac"),
    ])
    server.start()

    let manifestURL = server.url(forPath: "/live.m3u8")
    let proxyURL = proxy.proxyURL(for: manifestURL, headers: nil)

    // First fetch succeeds and is rewritten.
    let (data1, response1) = try fetch(proxyURL)
    let body1 = String(data: data1, encoding: .utf8) ?? ""
    XCTAssertEqual(response1.statusCode, 200)
    XCTAssertEqual(proxiedLines(in: body1).count, 2, "live manifest must still be rewritten")

    // A live (non-VOD) manifest must NOT be durably cached.
    let manifestKey = cache.cacheKey(for: manifestURL)
    Thread.sleep(forTimeInterval: 0.3)
    XCTAssertFalse(cache.isFullyCached(key: manifestKey),
                   "live (non-ENDLIST) manifest must never be cached")

    // Origin goes away — because nothing was cached, the proxy must surface the
    // upstream failure (502) rather than serving a stale body.
    server.stop()
    let (_, response2) = try fetch(proxyURL)
    XCTAssertEqual(response2.statusCode, 502,
                   "uncached live manifest must 502 when the origin is offline, never serve stale")
  }

  // MARK: - 6. Live (non-VOD) segments are served but never cached

  /// A live media playlist's segments are ephemeral, write-once-read-once data:
  /// the proxy must stream them to the player but must NOT persist them. Caching
  /// them would only churn the bounded LRU and evict reusable VOD content, so
  /// this makes correct `isLive` marking a performance hint rather than a
  /// requirement to avoid cache thrash — forgetting it degrades gracefully.
  func testLiveSegmentsServedButNotCached() throws {
    let livePlaylist = """
    #EXTM3U
    #EXT-X-VERSION:3
    #EXT-X-TARGETDURATION:1
    #EXT-X-MEDIA-SEQUENCE:0
    #EXTINF:1.0,
    seg0.aac
    #EXTINF:1.0,
    seg1.aac
    """
    let seg0 = Data(repeating: 0x11, count: 1500)
    let seg1 = Data(repeating: 0x22, count: 1500)
    let server = try LocalAudioServer(routes: [
      "/live.m3u8": .init(data: Data(livePlaylist.utf8), contentType: "application/vnd.apple.mpegurl"),
      "/seg0.aac": .init(data: seg0, contentType: "audio/aac"),
      "/seg1.aac": .init(data: seg1, contentType: "audio/aac"),
    ])
    server.start()
    defer { server.stop() }

    let manifestURL = server.url(forPath: "/live.m3u8")
    let (manifestData, _) = try fetch(proxy.proxyURL(for: manifestURL, headers: nil))
    let body = String(data: manifestData, encoding: .utf8) ?? ""

    let firstSegLine = try XCTUnwrap(proxiedLines(in: body).first,
                                     "live manifest must contain a proxied segment URL")
    let segProxyURL = try XCTUnwrap(URL(string: firstSegLine))

    // The segment is still streamed correctly to the player...
    let (segData, segResponse) = try fetch(segProxyURL)
    XCTAssertEqual(segResponse.statusCode, 200)
    XCTAssertEqual(segData, seg0, "a live segment must still be streamed through the proxy")

    // ...but it must NOT be persisted to the cache.
    let seg0Key = cache.cacheKey(for: server.url(forPath: "/seg0.aac"))
    Thread.sleep(forTimeInterval: 0.3)
    XCTAssertFalse(cache.isFullyCached(key: seg0Key),
                   "a live (non-VOD) segment must never be cached")
    XCTAssertEqual(cache.cachedBytes(for: seg0Key), 0,
                   "a live segment must leave no bytes in the cache")
  }

  // MARK: - 7. Re-serving a cached VOD manifest does not double its cached bytes

  /// Regression for the append-without-truncate hazard: fetching the same VOD
  /// manifest twice must leave the cached data file at exactly the raw manifest
  /// length, not doubled, and `contentLength` must match the data file size.
  func testCachedVODManifestNotDoubledOnRefetch() throws {
    let seg = Data(repeating: 0xEE, count: 512)
    let server = try makeHLSServer(seg0: seg, seg1: seg)
    server.start()
    defer { server.stop() }

    let manifestURL = server.url(forPath: "/index.m3u8")
    let proxyURL = proxy.proxyURL(for: manifestURL, headers: nil)
    let manifestKey = cache.cacheKey(for: manifestURL)

    let rawLength = Int64(Data(vodMediaPlaylist().utf8).count)

    // First fetch caches the raw VOD bytes.
    _ = try fetch(proxyURL)
    Thread.sleep(forTimeInterval: 0.3)
    XCTAssertTrue(cache.isFullyCached(key: manifestKey))
    XCTAssertEqual(cache.cachedBytes(for: manifestKey), rawLength,
                   "cached manifest data must equal the raw manifest byte length")

    // Second (and third) fetch must serve from cache and never re-append.
    _ = try fetch(proxyURL)
    _ = try fetch(proxyURL)
    Thread.sleep(forTimeInterval: 0.3)

    XCTAssertEqual(cache.cachedBytes(for: manifestKey), rawLength,
                   "re-serving a cached VOD manifest must not double its cached bytes")
    let info = try XCTUnwrap(cache.contentInfo(for: manifestKey))
    XCTAssertEqual(info.contentLength, cache.cachedBytes(for: manifestKey),
                   "contentLength must stay consistent with the cached data file size")
  }
}
