import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient

/// T-iOS-38 · Projects 契约增量（RO，无 Origin）：
/// - `GET /projects`（src/server.ts:262-269）→ `[ProjectInfo]`（src/types.ts:273-281 实际形状：
///   name/path/isGit 必填,branch/dirty/lastActiveMs 可选,sessions 为 ProjectSessionRef[]；
///   无 namespace 字段 —— namespace 是 web 端分组概念,只出现在 prefs.collapsed 的 key 里）；
/// - `GET /projects/detail?path=`（src/server.ts:293-310）→ `ProjectDetail`；
///   path 的 percent-encoding **只在 builder 一处**处理；400/404/500 `{error}` → 显式类型化错误。
/// 服务器是不可信输入源：未知字段忽略、畸形条目逐条丢弃、绝不 crash。
struct ProjectsTests {
    private static let base = "http://192.168.1.5:3000"
    private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"

    private static let fullProjectJSON = """
    {"name":"web-terminal","path":"/Users/dev/web-terminal","isGit":true,\
    "branch":"main","dirty":true,"lastActiveMs":1720000000000,\
    "sessions":[{"id":"\(sessionIdString)","title":"web-terminal","status":"working",\
    "clientCount":2,"createdAt":1719990000000,"exited":false}]}
    """

    private static let minimalProjectJSON = """
    {"name":"notes","path":"/Users/dev/notes","isGit":false,"sessions":[]}
    """

    private struct Fixture {
        let http: FakeHTTPTransport
        let client: APIClient
    }

    private func makeFixture() throws -> Fixture {
        let baseURL = try #require(URL(string: Self.base))
        let endpoint = try #require(HostEndpoint(baseURL: baseURL))
        let http = FakeHTTPTransport()
        return Fixture(http: http, client: APIClient(endpoint: endpoint, http: http))
    }

    private func routeURL(_ pathAndQuery: String) throws -> URL {
        try #require(URL(string: Self.base + pathAndQuery))
    }

    private func fetchProjects(_ fixture: Fixture, body: String) async throws -> [ProjectInfo] {
        await fixture.http.queueSuccess(url: try routeURL("/projects"), body: Data(body.utf8))
        return try await fixture.client.projects()
    }

    // MARK: - Origin iff-G（两个新 RO 端点都不带 Origin）

    @Test("Origin iff-G(RO 侧):GET /projects 与 GET /projects/detail 均不带 Origin")
    func projectsEndpointsAreReadOnlyWithoutOrigin() async throws {
        // Arrange
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(url: try routeURL("/projects"), body: Data("[]".utf8))
        await fixture.http.queueSuccess(
            url: try routeURL("/projects/detail?path=%2FUsers%2Fdev%2Fnotes"),
            body: Data(Self.detailJSON.utf8)
        )

        // Act
        _ = try await fixture.client.projects()
        _ = try await fixture.client.projectDetail(path: "/Users/dev/notes")

        // Assert
        let requests = await fixture.http.recordedRequests
        #expect(requests.count == 2)
        for request in requests {
            #expect(request.httpMethod == "GET")
            #expect(request.value(forHTTPHeaderField: "Origin") == nil)
        }
    }

    // MARK: - /projects 解码

    @Test("ProjectInfo 全字段样本解码(src/types.ts:273-281 实际形状,含 sessions ref)")
    func projectInfoDecodesFullSample() async throws {
        // Act
        let projects = try await fetchProjects(try makeFixture(), body: "[\(Self.fullProjectJSON)]")

        // Assert
        let expected = ProjectInfo(
            name: "web-terminal",
            path: "/Users/dev/web-terminal",
            isGit: true,
            branch: "main",
            dirty: true,
            lastActiveMs: 1_720_000_000_000,
            sessions: [ProjectSessionRef(
                id: try #require(UUID(uuidString: Self.sessionIdString)),
                title: "web-terminal",
                status: .working,
                clientCount: 2,
                createdAt: 1_719_990_000_000,
                exited: false
            )]
        )
        #expect(projects == [expected])
    }

    @Test("可选字段缺省(branch/dirty/lastActiveMs)与未知字段(向前兼容)均容忍,不丢条目")
    func optionalFieldsDegradeAndUnknownFieldsAreIgnored() async throws {
        // Arrange — 未知字段模拟未来服务器增量
        let body = """
        [{"name":"notes","path":"/Users/dev/notes","isGit":false,"sessions":[],\
        "futureField":{"nested":true},"favouriteRank":3}]
        """

        // Act
        let projects = try await fetchProjects(try makeFixture(), body: body)

        // Assert
        let project = try #require(projects.first)
        #expect(project.branch == nil)
        #expect(project.dirty == nil)
        #expect(project.lastActiveMs == nil)
        #expect(project.sessions.isEmpty)
    }

    @Test("畸形条目(缺必填/类型错/非对象)逐条丢弃,合法条目保留,不 crash")
    func malformedProjectEntriesAreDroppedWhileValidOnesSurvive() async throws {
        // Arrange
        let body = """
        [\(Self.fullProjectJSON),42,{"name":"x"},\
        {"name":1,"path":"/p","isGit":true,"sessions":[]},\(Self.minimalProjectJSON)]
        """

        // Act
        let projects = try await fetchProjects(try makeFixture(), body: body)

        // Assert
        #expect(projects.map(\.name) == ["web-terminal", "notes"])
    }

    @Test("session ref:未知 status → .unknown;畸形 ref 丢弃但项目保留;sessions 缺失 → 空数组")
    func sessionRefsAreTolerantlyDecoded() async throws {
        // Arrange
        let body = """
        [{"name":"a","path":"/a","isGit":true,"sessions":[\
        {"id":"\(Self.sessionIdString)","status":"hyperdrive","clientCount":0,\
        "createdAt":1,"exited":false},\
        {"id":"not-a-uuid","status":"idle","clientCount":0,"createdAt":2,"exited":true}]},\
        {"name":"b","path":"/b","isGit":false}]
        """

        // Act
        let projects = try await fetchProjects(try makeFixture(), body: body)

        // Assert
        #expect(projects.count == 2)
        let first = try #require(projects.first)
        #expect(first.sessions.count == 1) // 坏 UUID 的 ref 被丢弃
        #expect(first.sessions.first?.status == .unknown)
        #expect(first.sessions.first?.title == nil)
        #expect(projects.last?.sessions.isEmpty == true) // sessions 缺失 → []
    }

    @Test("/projects 非数组 body → invalidResponseBody;非 200 → unexpectedStatus")
    func projectsRejectsNonArrayBodyAndBadStatus() async throws {
        // Act + Assert — 非数组
        await #expect(throws: APIClientError.invalidResponseBody) {
            _ = try await self.fetchProjects(try self.makeFixture(), body: #"{"error":"x"}"#)
        }
        // Act + Assert — 非 200
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(url: try routeURL("/projects"), status: 500)
        await #expect(throws: APIClientError.unexpectedStatus(500)) {
            _ = try await fixture.client.projects()
        }
    }

    // MARK: - /projects/detail：percent-encoding 单点处理

    @Test("detail 的 path percent-encoding 只在 builder 一处:空格/+/&/=/非 ASCII 全部严格编码")
    func detailPathIsStrictlyPercentEncodedOnceInBuilder() async throws {
        // Arrange — '+' 必须编码为 %2B(Express qs 会把裸 '+' 解成空格);α = %CE%B1
        let fixture = try makeFixture()
        let rawPath = "/Users/dev/my proj+α&x=1"
        let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1%26x%3D1"
        let expectedURL = try routeURL("/projects/detail?path=\(encoded)")
        await fixture.http.queueSuccess(url: expectedURL, body: Data(Self.detailJSON.utf8))

        // Act
        _ = try await fixture.client.projectDetail(path: rawPath)

        // Assert — FakeHTTPTransport 按整 URL 精确匹配,能走到这里即编码逐字节正确
        let request = try #require(await fixture.http.recordedRequests.first)
        #expect(request.url == expectedURL)
    }

    // MARK: - /projects/detail 解码与错误映射

    private static let detailJSON = """
    {"name":"notes","path":"/Users/dev/notes","isGit":true,"branch":"main","dirty":false,\
    "worktrees":[{"path":"/Users/dev/notes","branch":"main","head":"abc1234",\
    "isMain":true,"isCurrent":true},\
    {"path":"/Users/dev/notes-wt","isMain":false,"isCurrent":false,"locked":true}],\
    "sessions":[],"hasClaudeMd":true,"claudeMd":"# Notes"}
    """

    @Test("ProjectDetail 全字段解码(worktrees 含可选字段缺省;src/types.ts:296-306)")
    func projectDetailDecodesFullSample() async throws {
        // Arrange
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(
            url: try routeURL("/projects/detail?path=%2FUsers%2Fdev%2Fnotes"),
            body: Data(Self.detailJSON.utf8)
        )

        // Act
        let detail = try await fixture.client.projectDetail(path: "/Users/dev/notes")

        // Assert
        #expect(detail.name == "notes")
        #expect(detail.isGit)
        #expect(detail.branch == "main")
        #expect(detail.dirty == false)
        #expect(detail.hasClaudeMd)
        #expect(detail.claudeMd == "# Notes")
        #expect(detail.worktrees.count == 2)
        let second = try #require(detail.worktrees.last)
        #expect(second.branch == nil) // detached HEAD → branch 缺省
        #expect(second.locked == true)
        #expect(second.prunable == nil)
        #expect(!second.isMain)
    }

    @Test("detail 容忍:hasClaudeMd 缺失 → false;畸形 worktree 条目丢弃;200 但 body 畸形 → invalidResponseBody")
    func projectDetailToleratesDegradedShapes() async throws {
        // Arrange — worktrees 里混入缺 path 的畸形条目
        let fixture = try makeFixture()
        let degraded = """
        {"name":"a","path":"/a","isGit":false,\
        "worktrees":[{"isMain":true},{"path":"/a","isMain":true,"isCurrent":true}],"sessions":[]}
        """
        let url = try routeURL("/projects/detail?path=%2Fa")
        await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8))
        await fixture.http.queueSuccess(url: url, body: Data("[1,2]".utf8))

        // Act
        let detail = try await fixture.client.projectDetail(path: "/a")

        // Assert
        #expect(detail.hasClaudeMd == false)
        #expect(detail.claudeMd == nil)
        #expect(detail.worktrees.count == 1)
        // Act + Assert — 200 但整体形状不对
        await #expect(throws: APIClientError.invalidResponseBody) {
            _ = try await fixture.client.projectDetail(path: "/a")
        }
    }

    @Test("detail 400/404/500 {error} → projectPathInvalid/projectNotFound/projectDetailUnavailable,各有非空话术")
    func projectDetailMapsErrorStatusesToTypedErrors() async throws {
        // Arrange
        let fixture = try makeFixture()
        let url = try routeURL("/projects/detail?path=%2Fgone")
        await fixture.http.queueSuccess(url: url, status: 400, body: Data(#"{"error":"path query parameter is required"}"#.utf8))
        await fixture.http.queueSuccess(url: url, status: 404, body: Data(#"{"error":"project not found"}"#.utf8))
        await fixture.http.queueSuccess(url: url, status: 500, body: Data(#"{"error":"failed to read project detail"}"#.utf8))

        // Act + Assert
        await #expect(throws: APIClientError.projectPathInvalid) {
            _ = try await fixture.client.projectDetail(path: "/gone")
        }
        await #expect(throws: APIClientError.projectNotFound) {
            _ = try await fixture.client.projectDetail(path: "/gone")
        }
        await #expect(throws: APIClientError.projectDetailUnavailable) {
            _ = try await fixture.client.projectDetail(path: "/gone")
        }
        for error in [APIClientError.projectPathInvalid, .projectNotFound, .projectDetailUnavailable] {
            #expect(!error.message.isEmpty)
        }
    }

    @Test("空 path → projectPathInvalid,联网前拒绝(镜像服务器 400 规则)")
    func emptyDetailPathIsRejectedBeforeNetwork() async throws {
        // Arrange
        let fixture = try makeFixture()

        // Act + Assert
        await #expect(throws: APIClientError.projectPathInvalid) {
            _ = try await fixture.client.projectDetail(path: "")
        }
        #expect(await fixture.http.recordedRequests.isEmpty)
    }
}
