// Unit tests for the game-library models — decoding the management API's GET /api/v1/library
// payload and the poster-art fallback order. (The network fetch itself isn't unit-tested; it's
// exercised live against a host.)

import XCTest
@testable import PunktfunkKit

final class LibraryClientTests: XCTestCase {
    func testDecodesLibraryPayload() throws {
        // A Steam entry (full art + launch) and a custom entry (sparse art, no launch) — the two
        // shapes the host's `GameEntry` serializes (note the host omits null fields).
        let json = """
        [
          {
            "id": "steam:570",
            "store": "steam",
            "title": "Dota 2",
            "art": {
              "portrait": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_600x900.jpg",
              "hero": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_hero.jpg",
              "logo": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/logo.png",
              "header": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/header.jpg"
            },
            "launch": { "kind": "steam_appid", "value": "570" }
          },
          {
            "id": "custom:abc123",
            "store": "custom",
            "title": "Dolphin",
            "platform": "GameCube",
            "art": { "header": "https://example.com/dolphin.jpg" }
          }
        ]
        """.data(using: .utf8)!

        let games = try JSONDecoder().decode([GameEntry].self, from: json)
        XCTAssertEqual(games.count, 2)

        let steam = games[0]
        XCTAssertEqual(steam.id, "steam:570")
        XCTAssertFalse(steam.isCustom)
        XCTAssertEqual(steam.launch?.kind, "steam_appid")
        XCTAssertEqual(steam.launch?.value, "570")

        let custom = games[1]
        XCTAssertTrue(custom.isCustom)
        XCTAssertNil(custom.launch)
        XCTAssertNil(custom.art.portrait)
        // `platform` is the host's flattened `GameMeta.platform`: present on a rom-manager entry,
        // absent (nil) on a store-front title — the field the library's collections group by.
        XCTAssertEqual(custom.platform, "GameCube")
        XCTAssertNil(steam.platform)
    }

    func testPosterCandidatesPreferPortraitThenHeader() {
        let full = Artwork(
            portrait: "https://x/p.jpg", hero: "https://x/hero.jpg",
            logo: "https://x/logo.png", header: "https://x/h.jpg")
        XCTAssertEqual(full.posterCandidates.map(\.absoluteString),
                       ["https://x/p.jpg", "https://x/h.jpg", "https://x/hero.jpg"])

        // No portrait → header leads; absent fields are skipped, not nil-padded.
        let sparse = Artwork(portrait: nil, hero: nil, logo: nil, header: "https://x/h.jpg")
        XCTAssertEqual(sparse.posterCandidates.map(\.absoluteString), ["https://x/h.jpg"])

        XCTAssertTrue(Artwork().posterCandidates.isEmpty)
    }

    func testArtworkResolvedRewritesOnlyHostRelativePaths() {
        let base = URL(string: "https://192.168.1.70:47990/api/v1/library")!
        // Steam art now comes back as host-relative proxy paths; external CDN URLs (GOG/Heroic/Xbox)
        // and `data:` URLs (Lutris) are untouched.
        let art = Artwork(
            portrait: "/api/v1/library/art/steam:3527290/portrait",
            hero: "https://cdn.example.com/hero.jpg",
            logo: nil,
            header: "/api/v1/library/art/steam:3527290/header")
        let resolved = art.resolved(against: base)
        XCTAssertEqual(
            resolved.portrait, "https://192.168.1.70:47990/api/v1/library/art/steam:3527290/portrait")
        XCTAssertEqual(
            resolved.header, "https://192.168.1.70:47990/api/v1/library/art/steam:3527290/header")
        XCTAssertEqual(resolved.hero, "https://cdn.example.com/hero.jpg") // unchanged
        XCTAssertNil(resolved.logo)
    }

    // MARK: - HTTP response parsing (MgmtTransport)

    // The management API is reached over Network.framework rather than URLSession (ATS cannot be
    // relaxed for the arbitrary addresses a host lives at — see MgmtTransport), so we parse HTTP
    // ourselves. These cover the framings hyper actually emits, plus the failure modes where
    // getting it wrong would be silent.

    private func raw(_ text: String) -> Data { Data(text.utf8) }

    func testParsesContentLengthFramedJSON() throws {
        let body = #"[{"id":"steam:570"}]"#
        let response = try HTTPResponseParser.parse(raw(
            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
                + "Content-Length: \(body.utf8.count)\r\n\r\n\(body)"))
        XCTAssertEqual(response.status, 200)
        XCTAssertEqual(String(decoding: response.body, as: UTF8.self), body)
        // Field names are case-insensitive per RFC 9110.
        XCTAssertEqual(response.header("CONTENT-TYPE"), "application/json")
    }

    func testParsesChunkedBody() throws {
        // How hyper streams the art proxy.
        let response = try HTTPResponseParser.parse(raw(
            "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
                + "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"))
        XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "hello world")
    }

    func testChunkExtensionsAndTrailersAreIgnored() throws {
        let response = try HTTPResponseParser.parse(raw(
            "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
                + "3;foo=bar\r\nabc\r\n0\r\nX-Trailer: 1\r\n\r\n"))
        XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "abc")
    }

    func testUnauthorizedStatusSurvives() throws {
        // What an unpaired certificate gets from the host — the status is the whole signal.
        let response = try HTTPResponseParser.parse(
            raw("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"))
        XCTAssertEqual(response.status, 401)
        XCTAssertTrue(response.body.isEmpty)
    }

    func testBodyRunsToEOFWithoutFramingHeaders() throws {
        let response = try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\n\r\nraw-to-eof"))
        XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "raw-to-eof")
    }

    func testTruncatedBodyThrowsRatherThanReturningPartialJSON() {
        // The one that matters: a body cut short must NOT come back as success. A clipped JSON
        // array would decode to fewer games — "this host has no games" — instead of an error.
        XCTAssertThrowsError(
            try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\nContent-Length: 99\r\n\r\nshort")))
    }

    func testOverLongBodyIsClippedToContentLength() throws {
        let response = try HTTPResponseParser.parse(
            raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabcdef"))
        XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "abc")
    }

    func testMalformedResponsesThrow() {
        // Header block never terminated (peer hung up), a non-HTTP greeting, and a chunked stream
        // cut mid-chunk.
        XCTAssertThrowsError(
            try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n")))
        XCTAssertThrowsError(try HTTPResponseParser.parse(raw("NOT-HTTP\r\n\r\n")))
        XCTAssertThrowsError(try HTTPResponseParser.parse(raw(
            "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n9\r\nabc")))
    }

    func testMultiWordReasonPhraseParses() throws {
        let response = try HTTPResponseParser.parse(
            raw("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"))
        XCTAssertEqual(response.status, 404)
    }

    func testBinaryBodySurvivesByteForByte() throws {
        // Posters are PNG/JPEG: the body must never be round-tripped through a String.
        let png: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF, 0x0D, 0x0A]
        var message = raw("HTTP/1.1 200 OK\r\nContent-Length: \(png.count)\r\n\r\n")
        message.append(contentsOf: png)
        let response = try HTTPResponseParser.parse(message)
        XCTAssertEqual([UInt8](response.body), png)
    }

    // MARK: - Message framing (keep-alive)

    // Connections are pooled and reused, so a response has to be delimited WITHOUT waiting for
    // the peer to hang up. Getting this wrong either truncates a response or bleeds one response
    // into the next, and both would be silent.

    func testMessageLengthDelimitsContentLengthFraming() throws {
        let complete = raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
        XCTAssertEqual(try HTTPResponseParser.messageLength(in: complete), complete.count)
        XCTAssertNil(try HTTPResponseParser.messageLength(
            in: raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhel")))
    }

    func testMessageLengthDelimitsChunkedFraming() throws {
        let complete = raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n")
        XCTAssertEqual(try HTTPResponseParser.messageLength(in: complete), complete.count)
        // Mid-chunk, and terminal chunk without its closing blank line.
        XCTAssertNil(try HTTPResponseParser.messageLength(
            in: raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhel")))
        XCTAssertNil(try HTTPResponseParser.messageLength(
            in: raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n")))
        // Trailers belong to the message and must be consumed with it.
        let trailered = raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
            + "3\r\nabc\r\n0\r\nX-Trailer: 1\r\n\r\n")
        XCTAssertEqual(try HTTPResponseParser.messageLength(in: trailered), trailered.count)
    }

    func testMessageLengthIsNilWithoutFraming() throws {
        // No Content-Length and not chunked ⇒ the body runs to EOF and the connection can't be
        // reused. Partial header blocks are likewise "not yet".
        XCTAssertNil(try HTTPResponseParser.messageLength(in: raw("HTTP/1.1 200 OK\r\n\r\nto-eof")))
        XCTAssertNil(try HTTPResponseParser.messageLength(in: raw("HTTP/1.1 200 OK\r\nContent-Len")))
    }

    func testBackToBackResponsesSplitExactly() throws {
        // The reuse case that matters: two responses arriving in one read.
        let first = raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
        let second = raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nbye")
        let both = first + second
        XCTAssertEqual(try HTTPResponseParser.messageLength(in: both), first.count)
        XCTAssertEqual(
            String(decoding: try HTTPResponseParser.parse(both.prefix(first.count)).body,
                   as: UTF8.self), "hello")
        XCTAssertEqual(
            String(decoding: try HTTPResponseParser.parse(both.dropFirst(first.count)).body,
                   as: UTF8.self), "bye")
    }

    func testConnectionCloseIsDetected() throws {
        let response = try HTTPResponseParser.parse(
            raw("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"))
        XCTAssertTrue(response.wantsClose)
        // HTTP/1.1 keeps the connection open unless told otherwise.
        XCTAssertFalse(try HTTPResponseParser.parse(
            raw("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")).wantsClose)
    }

    // MARK: - Art cache

    private func temporaryCacheDirectory() -> URL {
        URL(fileURLWithPath: NSTemporaryDirectory())
            .appendingPathComponent("pf-art-test-\(UUID().uuidString)", isDirectory: true)
    }

    func testArtCacheRoundTripsBinaryAndSeparatesKeys() async throws {
        let directory = temporaryCacheDirectory()
        defer { try? FileManager.default.removeItem(at: directory) }
        let cache = ArtCache(directory: directory)

        let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0x0D, 0x0A])
        let portrait = URL(string: "https://100.64.1.2:47990/api/v1/library/art/steam:570/portrait")!
        let header = URL(string: "https://100.64.1.2:47990/api/v1/library/art/steam:570/header")!

        var hit = await cache.data(for: portrait)
        XCTAssertNil(hit, "cold cache must miss")
        await cache.store(png, for: portrait)
        hit = await cache.data(for: portrait)
        XCTAssertEqual(hit, png, "posters are binary; the body must survive byte-for-byte")
        // Sibling art of the same title must not collide.
        let sibling = await cache.data(for: header)
        XCTAssertNil(sibling)
    }

    func testArtCacheRefusesEmptyAndInlineData() async throws {
        let directory = temporaryCacheDirectory()
        defer { try? FileManager.default.removeItem(at: directory) }
        let cache = ArtCache(directory: directory)

        let empty = URL(string: "https://cdn.example.com/empty.jpg")!
        await cache.store(Data(), for: empty)
        let emptyHit = await cache.data(for: empty)
        XCTAssertNil(emptyHit, "an empty body is not art")

        let inline = URL(string: "data:image/png;base64,iVBORw0KGgo=")!
        await cache.store(Data("x".utf8), for: inline)
        let inlineHit = await cache.data(for: inline)
        XCTAssertNil(inlineHit, "a data: URL is already inline — caching it is a pure loss")
    }

    func testArtCacheAgesEntriesOut() async throws {
        let directory = temporaryCacheDirectory()
        defer { try? FileManager.default.removeItem(at: directory) }
        let cache = ArtCache(directory: directory, maxAge: 0.4)
        let url = URL(string: "https://cdn.example.com/stale.jpg")!

        await cache.store(Data("stale".utf8), for: url)
        let fresh = await cache.data(for: url)
        XCTAssertNotNil(fresh)
        try await Task.sleep(nanoseconds: 700_000_000)
        let expired = await cache.data(for: url)
        XCTAssertNil(expired)
    }

    func testArtCacheEvictsLeastRecentlyUsedOverBudget() async throws {
        let directory = temporaryCacheDirectory()
        defer { try? FileManager.default.removeItem(at: directory) }
        // Budget holds three of these; the fourth store must evict.
        let cache = ArtCache(directory: directory, maxBytes: 300)
        let blob = Data(repeating: 0x41, count: 100)
        var urls: [URL] = []
        for i in 0..<4 {
            let url = URL(string: "https://cdn.example.com/blob\(i).jpg")!
            urls.append(url)
            await cache.store(blob, for: url)
            try await Task.sleep(nanoseconds: 60_000_000) // distinct mtimes for LRU ordering
        }
        let evicted = await cache.data(for: urls[0])
        XCTAssertNil(evicted, "the oldest entry should have been evicted")
        let newest = await cache.data(for: urls[3])
        XCTAssertEqual(newest, blob)
    }

    func testBaseURLBracketsIPv6Only() {
        XCTAssertEqual(LibraryClient.baseURL(address: "192.168.1.70", port: 47990),
                       "https://192.168.1.70:47990")
        XCTAssertEqual(LibraryClient.baseURL(address: "100.101.102.103", port: 47990),
                       "https://100.101.102.103:47990")
        XCTAssertEqual(LibraryClient.baseURL(address: "fd7a:115c::1", port: 47990),
                       "https://[fd7a:115c::1]:47990")
        // An address the user pasted already bracketed must not end up double-bracketed.
        XCTAssertEqual(LibraryClient.baseURL(address: "[fd7a:115c::1]", port: 47990),
                       "https://[fd7a:115c::1]:47990")
    }
}
