import Foundation
import Flutter
import XCTest

@testable import Runner

private final class ShelfArtworkURLProtocol: URLProtocol {
  private static let lock = NSLock()
  private static var startHandler: ((ShelfArtworkURLProtocol) -> Void)?
  private static var stopHandler: (() -> Void)?

  static func configure(
    start: @escaping (ShelfArtworkURLProtocol) -> Void,
    stop: (() -> Void)? = nil
  ) {
    lock.lock()
    startHandler = start
    stopHandler = stop
    lock.unlock()
  }

  static func reset() {
    lock.lock()
    startHandler = nil
    stopHandler = nil
    lock.unlock()
  }

  override class func canInit(with request: URLRequest) -> Bool { true }

  override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

  override func startLoading() {
    Self.lock.lock()
    let handler = Self.startHandler
    Self.lock.unlock()
    guard let handler else {
      client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
      return
    }
    handler(self)
  }

  override func stopLoading() {
    Self.lock.lock()
    let handler = Self.stopHandler
    Self.lock.unlock()
    handler?()
  }

  func sendResponse(contentLength: Int? = nil) {
    var headers = ["Content-Type": "image/png"]
    if let contentLength { headers["Content-Length"] = String(contentLength) }
    let response = HTTPURLResponse(
      url: request.url!,
      statusCode: 200,
      httpVersion: "HTTP/1.1",
      headerFields: headers
    )!
    client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
  }

  func send(_ data: Data) {
    client?.urlProtocol(self, didLoad: data)
  }

  func finish() {
    client?.urlProtocolDidFinishLoading(self)
  }
}

private final class SystemShelfSyncHarness {
  let defaults: UserDefaults
  let root: URL
  private(set) var state = SystemShelfMutationState()
  private(set) var epoch: UInt64 = 0

  private struct ScheduledPrune {
    let deadline: TimeInterval
    let batch: SystemShelfPruneBatch
  }

  private let suiteName: String
  private var pruneClock: TimeInterval = 0
  private var scheduledPrunes: [ScheduledPrune] = []

  var now: () -> Date = { Date(timeIntervalSince1970: 1_000) }
  var loaderShouldFail = false
  private(set) var requestedURLs: [URL] = []
  private(set) var notificationCount = 0

  init() throws {
    suiteName = "SystemShelfSyncHarness.\(UUID().uuidString)"
    defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
    root = FileManager.default.temporaryDirectory
      .appendingPathComponent(UUID().uuidString, isDirectory: true)
    epoch = state.beginEngineSession()
    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
  }

  func cleanup() {
    defaults.removePersistentDomain(forName: suiteName)
    try? FileManager.default.removeItem(at: root)
  }

  @discardableResult
  func beginEngineSession() -> UInt64 {
    epoch = state.beginEngineSession()
    return epoch
  }

  func sync(
    generation: Int64,
    items: [[String: Any]],
    ownerId: String = "profile-a",
    engineEpoch: UInt64? = nil
  ) -> Bool {
    let environment = SystemShelfSyncEnvironment(
      defaults: defaults,
      artworkRoot: root,
      now: now,
      loadArtwork: { [unowned self] url, _, _ in
        requestedURLs.append(url)
        guard !loaderShouldFail else { return nil }
        return BoundedArtworkDownload(data: Self.png, mimeType: "image/png")
      },
      schedulePrune: { [unowned self] batches in
        scheduledPrunes.append(
          contentsOf: batches.map {
            ScheduledPrune(deadline: pruneClock + $0.delay, batch: $0)
          }
        )
      },
      notifyChange: { [unowned self] in
        notificationCount += 1
      }
    )
    return SystemShelfPlugin.sync(
      envelope: envelope(
        generation: generation,
        ownerId: ownerId,
        engineEpoch: engineEpoch
      ),
      rawItems: items,
      state: &state,
      environment: environment
    )
  }

  func clear(generation: Int64, ownerId: String = "profile-a") -> Bool {
    SystemShelfPlugin.clearCache(
      envelope: envelope(generation: generation, ownerId: ownerId),
      state: &state,
      defaults: defaults,
      artworkRoot: root,
      notifyChange: { [unowned self] in
        notificationCount += 1
      }
    )
  }

  func remove(generation: Int64, contentId: String) -> Bool {
    SystemShelfPlugin.removeItem(
      envelope: envelope(generation: generation),
      contentId: contentId,
      state: &state,
      defaults: defaults,
      artworkRoot: root,
      schedulePrune: { [unowned self] batches in
        scheduledPrunes.append(
          contentsOf: batches.map {
            ScheduledPrune(deadline: pruneClock + $0.delay, batch: $0)
          }
        )
      },
      notifyChange: { [unowned self] in
        notificationCount += 1
      }
    )
  }

  func envelope(
    generation: Int64,
    ownerId: String = "profile-a",
    engineEpoch: UInt64? = nil
  ) -> SystemShelfMutationEnvelope {
    SystemShelfMutationEnvelope(
      ownerId: ownerId,
      generation: generation,
      engineEpoch: engineEpoch ?? epoch
    )
  }

  func currentItems() throws -> [[String: Any]] {
    let data = try XCTUnwrap(defaults.data(forKey: SystemShelfPlugin.cacheDataKey))
    let payload = try XCTUnwrap(
      JSONSerialization.jsonObject(with: data) as? [String: Any]
    )
    let sections = try XCTUnwrap(payload["sections"] as? [[String: Any]])
    return sections.flatMap { $0["items"] as? [[String: Any]] ?? [] }
  }

  func artworkURL(for key: String) -> URL? {
    guard
      let enumerator = FileManager.default.enumerator(
        at: root,
        includingPropertiesForKeys: nil
      )
    else { return nil }
    return enumerator.compactMap { $0 as? URL }.first { $0.lastPathComponent == key }
  }

  func advancePrunes(by interval: TimeInterval) {
    pruneClock += interval
    let due = scheduledPrunes.filter { $0.deadline <= pruneClock }
    scheduledPrunes.removeAll { $0.deadline <= pruneClock }
    for scheduled in due {
      let candidates = state.claimPruning(scheduled.batch)
      SystemShelfPlugin.pruneUnreferenced(
        candidates,
        defaults: defaults,
        root: root
      )
    }
  }

  static func item(
    source: String? = nil,
    progress: Int = 0,
    contentId: String = "movie-1"
  ) -> [String: Any] {
    var item: [String: Any] = [
      "contentId": contentId,
      "title": "Movie",
      "lastPlaybackPosition": progress,
    ]
    if let source {
      item["posterSourceUri"] = source
    }
    return item
  }

  static let png = Data(
    base64Encoded:
      "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  )!
}

final class SystemShelfPluginTests: XCTestCase {
  override func tearDown() {
    ShelfArtworkURLProtocol.reset()
    super.tearDown()
  }

  func testSyncRemoveAndClearUpdateCommittedShelf() throws {
    let harness = try SystemShelfSyncHarness()
    defer { harness.cleanup() }

    XCTAssertTrue(
      harness.sync(
        generation: 1,
        items: [
          SystemShelfSyncHarness.item(
            source: "https://shelf.test/a.png",
            contentId: "movie-a"
          ),
          SystemShelfSyncHarness.item(
            source: "https://shelf.test/b.png",
            contentId: "movie-b"
          ),
        ]
      )
    )
    let initialItems = try harness.currentItems()
    XCTAssertEqual(Set(initialItems.compactMap { $0["contentId"] as? String }), ["movie-a", "movie-b"])
    let removedKey = try XCTUnwrap(
      initialItems.first { $0["contentId"] as? String == "movie-a" }?["artworkKey"] as? String
    )
    let removedArtwork = try XCTUnwrap(harness.artworkURL(for: removedKey))

    XCTAssertTrue(harness.remove(generation: 2, contentId: "movie-a"))
    XCTAssertEqual(try harness.currentItems().compactMap { $0["contentId"] as? String }, ["movie-b"])
    XCTAssertTrue(FileManager.default.fileExists(atPath: removedArtwork.path))
    harness.advancePrunes(by: 60)
    XCTAssertFalse(FileManager.default.fileExists(atPath: removedArtwork.path))

    XCTAssertTrue(harness.clear(generation: 3))
    XCTAssertNil(harness.defaults.data(forKey: SystemShelfPlugin.cacheDataKey))
    XCTAssertFalse(FileManager.default.fileExists(atPath: harness.root.path))
    XCTAssertEqual(harness.notificationCount, 3)
  }

  func testReplacementEngineRejectsStaleEngineMutation() throws {
    let harness = try SystemShelfSyncHarness()
    defer { harness.cleanup() }
    let staleEpoch = harness.epoch

    XCTAssertTrue(
      harness.sync(
        generation: 50,
        items: [SystemShelfSyncHarness.item(progress: 50)]
      )
    )
    _ = harness.beginEngineSession()
    XCTAssertTrue(
      harness.sync(
        generation: 1,
        items: [SystemShelfSyncHarness.item(progress: 1)],
        ownerId: "profile-b"
      )
    )

    XCTAssertFalse(
      harness.sync(
        generation: 51,
        items: [SystemShelfSyncHarness.item(progress: 99)],
        engineEpoch: staleEpoch
      )
    )
    let item = try XCTUnwrap(harness.currentItems().first)
    XCTAssertEqual(item["lastPlaybackPosition"] as? Int, 1)
    let data = try XCTUnwrap(
      harness.defaults.data(forKey: SystemShelfPlugin.cacheDataKey)
    )
    let payload = try XCTUnwrap(
      JSONSerialization.jsonObject(with: data) as? [String: Any]
    )
    XCTAssertEqual(payload["ownerId"] as? String, "profile-b")
  }

  func testHigherGenerationRemainsLastWriter() throws {
    let harness = try SystemShelfSyncHarness()
    defer { harness.cleanup() }

    XCTAssertTrue(
      harness.sync(
        generation: 2,
        items: [SystemShelfSyncHarness.item(progress: 20)]
      )
    )
    XCTAssertFalse(
      harness.sync(
        generation: 1,
        items: [SystemShelfSyncHarness.item(progress: 10)]
      )
    )
    XCTAssertEqual(
      try harness.currentItems().first?["lastPlaybackPosition"] as? Int,
      20
    )
    XCTAssertEqual(harness.notificationCount, 1)
  }

  func testProgressOnlySyncReusesCommittedArtworkWithoutRequestingItAgain() throws {
    let harness = try SystemShelfSyncHarness()
    defer { harness.cleanup() }
    let source = "https://shelf.test/movie-a.png"

    XCTAssertTrue(
      harness.sync(
        generation: 1,
        items: [SystemShelfSyncHarness.item(source: source, progress: 10)]
      )
    )
    let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
    let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))

    XCTAssertTrue(
      harness.sync(
        generation: 2,
        items: [SystemShelfSyncHarness.item(source: source, progress: 45)]
      )
    )
    let updatedItem = try XCTUnwrap(harness.currentItems().first)
    XCTAssertEqual(updatedItem["lastPlaybackPosition"] as? Int, 45)
    XCTAssertEqual(updatedItem["artworkKey"] as? String, firstKey)
    XCTAssertEqual(harness.requestedURLs.map(\.absoluteString), [source])
    XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
  }

  func testFailedReplacementKeepsLastCommittedArtwork() throws {
    let harness = try SystemShelfSyncHarness()
    defer { harness.cleanup() }
    let firstSource = "https://shelf.test/movie-a.png"

    XCTAssertTrue(
      harness.sync(
        generation: 1,
        items: [SystemShelfSyncHarness.item(source: firstSource)]
      )
    )
    let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
    let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))

    harness.loaderShouldFail = true
    XCTAssertTrue(
      harness.sync(
        generation: 2,
        items: [
          SystemShelfSyncHarness.item(source: "https://shelf.test/movie-b.png", progress: 20)
        ]
      )
    )

    let item = try XCTUnwrap(harness.currentItems().first)
    XCTAssertEqual(item["artworkKey"] as? String, firstKey)
    XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
    XCTAssertEqual(
      harness.requestedURLs.map(\.absoluteString),
      [firstSource, "https://shelf.test/movie-b.png"]
    )
  }

  func testEachSupersededArtworkReceivesFullPruneGrace() throws {
    let harness = try SystemShelfSyncHarness()
    defer { harness.cleanup() }

    XCTAssertTrue(
      harness.sync(
        generation: 1,
        items: [SystemShelfSyncHarness.item(source: "https://shelf.test/a.png")]
      )
    )
    let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
    let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
    XCTAssertTrue(
      harness.sync(
        generation: 2,
        items: [SystemShelfSyncHarness.item(source: "https://shelf.test/b.png")]
      )
    )
    let secondKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
    let secondArtwork = try XCTUnwrap(harness.artworkURL(for: secondKey))

    harness.advancePrunes(by: 59)
    XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
    XCTAssertTrue(
      harness.sync(
        generation: 3,
        items: [SystemShelfSyncHarness.item(source: "https://shelf.test/c.png")]
      )
    )
    let currentKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
    let currentArtwork = try XCTUnwrap(harness.artworkURL(for: currentKey))

    harness.advancePrunes(by: 1)
    XCTAssertFalse(FileManager.default.fileExists(atPath: firstArtwork.path))
    XCTAssertTrue(FileManager.default.fileExists(atPath: secondArtwork.path))
    harness.advancePrunes(by: 58)
    XCTAssertTrue(FileManager.default.fileExists(atPath: secondArtwork.path))
    harness.advancePrunes(by: 1)
    XCTAssertFalse(FileManager.default.fileExists(atPath: secondArtwork.path))
    XCTAssertTrue(FileManager.default.fileExists(atPath: currentArtwork.path))
  }

  func testPruneRechecksCurrentArtworkAndRelaunchRecoveryPreservesIt() throws {
    let harness = try SystemShelfSyncHarness()
    defer { harness.cleanup() }

    XCTAssertTrue(
      harness.sync(
        generation: 1,
        items: [SystemShelfSyncHarness.item(source: "https://shelf.test/a.png")]
      )
    )
    let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
    let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
    XCTAssertTrue(
      harness.sync(
        generation: 2,
        items: [SystemShelfSyncHarness.item(source: "https://shelf.test/b.png")]
      )
    )
    let secondKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
    let secondArtwork = try XCTUnwrap(harness.artworkURL(for: secondKey))

    try replaceCommittedArtworkKey(
      in: harness.defaults,
      with: firstKey
    )
    harness.advancePrunes(by: 60)
    XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))

    let relaunchedAt = Date(timeIntervalSince1970: 10_000)
    let recovered = SystemShelfPlugin.recoverableArtwork(
      root: harness.root,
      keeping: [firstArtwork],
      now: relaunchedAt
    )
    XCTAssertEqual(
      recovered[secondArtwork],
      relaunchedAt.addingTimeInterval(60)
    )
    XCTAssertNil(recovered[firstArtwork])

    var relaunchedState = SystemShelfMutationState()
    _ = relaunchedState.beginEngineSession()
    XCTAssertNotNil(relaunchedState.adoptPersistedOwner("profile-a"))
    relaunchedState.cancelPruning(keeping: [firstArtwork])
    let batches = relaunchedState.preparePruning(
      removing: Set(recovered.keys),
      after: 60
    )
    XCTAssertEqual(batches.count, 1)
    XCTAssertEqual(batches.first?.delay, 60)
    let candidates = relaunchedState.claimPruning(try XCTUnwrap(batches.first))
    SystemShelfPlugin.pruneUnreferenced(
      candidates,
      defaults: harness.defaults,
      root: harness.root
    )
    XCTAssertFalse(FileManager.default.fileExists(atPath: secondArtwork.path))
    XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
  }

  func testPruneDoesNotEscapeArtworkRoot() throws {
    let harness = try SystemShelfSyncHarness()
    defer { harness.cleanup() }
    let outside = FileManager.default.temporaryDirectory
      .appendingPathComponent("\(UUID().uuidString).art")
    let inside = harness.root.appendingPathComponent("orphan.art")
    let outsideDirectory = FileManager.default.temporaryDirectory
      .appendingPathComponent(UUID().uuidString, isDirectory: true)
    let outsideViaSymlink = outsideDirectory.appendingPathComponent("linked.art")
    let linkedDirectory = harness.root.appendingPathComponent("linked", isDirectory: true)
    let linkedCandidate = linkedDirectory.appendingPathComponent("linked.art")
    try Data([0x01]).write(to: outside)
    try Data([0x02]).write(to: inside)
    try FileManager.default.createDirectory(
      at: outsideDirectory,
      withIntermediateDirectories: true
    )
    try Data([0x03]).write(to: outsideViaSymlink)
    try FileManager.default.createSymbolicLink(
      at: linkedDirectory,
      withDestinationURL: outsideDirectory
    )
    defer {
      try? FileManager.default.removeItem(at: outside)
      try? FileManager.default.removeItem(at: outsideDirectory)
    }

    SystemShelfPlugin.pruneUnreferenced(
      [outside, inside, linkedCandidate],
      defaults: harness.defaults,
      root: harness.root
    )

    XCTAssertTrue(FileManager.default.fileExists(atPath: outside.path))
    XCTAssertTrue(FileManager.default.fileExists(atPath: outsideViaSymlink.path))
    XCTAssertFalse(FileManager.default.fileExists(atPath: inside.path))
  }

  func testArtworkLoaderCancelsUnknownLengthBodyAtConfiguredCap() {
    let stopped = expectation(description: "oversized request cancelled")
    ShelfArtworkURLProtocol.configure(
      start: { request in
        request.sendResponse()
        request.send(Data(repeating: 0x41, count: 5))
        request.send(Data(repeating: 0x42, count: 5))
        request.finish()
      },
      stop: { stopped.fulfill() }
    )
    let configuration = URLSessionConfiguration.ephemeral
    configuration.protocolClasses = [ShelfArtworkURLProtocol.self]
    let loader = BoundedArtworkLoader(maximumBytes: 8, configuration: configuration)

    let result = loader.load(url: URL(string: "https://shelf.test/art.png")!, timeout: 1)

    XCTAssertNil(result)
    XCTAssertTrue(loader.exceededLimit)
    XCTAssertLessThanOrEqual(loader.peakBufferedBytes, 8)
    wait(for: [stopped], timeout: 1)
  }

  func testArtworkLoaderReturnsBoundedChunkedBodyAndMimeType() {
    let expected = Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07])
    ShelfArtworkURLProtocol.configure { request in
      request.sendResponse()
      request.send(expected.prefix(3))
      request.send(expected.suffix(4))
      request.finish()
    }
    let configuration = URLSessionConfiguration.ephemeral
    configuration.protocolClasses = [ShelfArtworkURLProtocol.self]
    let loader = BoundedArtworkLoader(maximumBytes: 8, configuration: configuration)

    let result = loader.load(url: URL(string: "https://shelf.test/art.png")!, timeout: 1)

    XCTAssertEqual(result?.data, expected)
    XCTAssertEqual(result?.mimeType, "image/png")
    XCTAssertEqual(loader.peakBufferedBytes, expected.count)
    XCTAssertFalse(loader.exceededLimit)
  }

  private func replaceCommittedArtworkKey(
    in defaults: UserDefaults,
    with key: String
  ) throws {
    let data = try XCTUnwrap(
      defaults.data(forKey: SystemShelfPlugin.cacheDataKey)
    )
    var payload = try XCTUnwrap(
      JSONSerialization.jsonObject(with: data) as? [String: Any]
    )
    var sections = try XCTUnwrap(payload["sections"] as? [[String: Any]])
    var items = try XCTUnwrap(sections.first?["items"] as? [[String: Any]])
    items[0]["artworkKey"] = key
    sections[0]["items"] = items
    payload["sections"] = sections
    defaults.set(
      try JSONSerialization.data(withJSONObject: payload),
      forKey: SystemShelfPlugin.cacheDataKey
    )
  }
}
