//
//  LoginViewModelTests.swift
//  suixinkanTests
//
//  Created by Codex on 2026/6/20.
//

import XCTest
@testable import suixinkan

@MainActor
/// 登录页 ViewModel 测试，覆盖手机号规范化、表单校验、登录流程和账号选择。
final class LoginViewModelTests: XCTestCase {
    /// 测试 +86 手机号会被规范化为 11 位国内手机号。
    func testNormalizeUsernameCountryCodeRemovesChinaPrefix() {
        let viewModel = LoginViewModel()
        viewModel.username = "+86 186 5185 7230"

        viewModel.normalizeUsernameCountryCodeIfNeeded()

        XCTAssertEqual(viewModel.username, "18651857230")
        XCTAssertEqual(viewModel.normalizedUsername, "18651857230")
        XCTAssertTrue(viewModel.isValidPhone)
    }

    /// 测试有效手机号和密码时允许提交。
    func testCanSubmitRequiresValidPhoneAndNonEmptyPassword() {
        let viewModel = LoginViewModel()
        viewModel.username = "18651857230"
        viewModel.password = " secret123 "

        XCTAssertTrue(viewModel.canSubmit)
        XCTAssertEqual(viewModel.trimmedPassword, "secret123")
    }

    /// 测试无效手机号时不允许提交。
    func testCanSubmitIsFalseForInvalidPhone() {
        let viewModel = LoginViewModel()
        viewModel.username = "12345"
        viewModel.password = "secret123"

        XCTAssertFalse(viewModel.canSubmit)
        XCTAssertFalse(viewModel.isValidPhone)
    }

    /// 测试登录校验会按顺序返回手机号、密码和协议错误。
    func testValidateForLoginReturnsExpectedErrorsInOrder() {
        let viewModel = LoginViewModel()

        viewModel.username = "12345"
        viewModel.password = "secret123"
        viewModel.privacyChecked = true
        XCTAssertEqual(viewModel.validateForLogin(), .invalidPhone)

        viewModel.username = "18651857230"
        viewModel.password = "   "
        XCTAssertEqual(viewModel.validateForLogin(), .emptyPassword)

        viewModel.password = "secret123"
        viewModel.privacyChecked = false
        XCTAssertEqual(viewModel.validateForLogin(), .privacyUnchecked)

        viewModel.privacyChecked = true
        XCTAssertNil(viewModel.validateForLogin())
    }

    /// 测试未勾选协议时登录校验返回隐私协议错误。
    func testValidateForLoginRequiresPrivacyAgreement() {
        let viewModel = LoginViewModel()
        viewModel.username = "18651857230"
        viewModel.password = "secret123"
        viewModel.privacyChecked = false

        XCTAssertEqual(viewModel.validateForLogin(), .privacyUnchecked)
    }

    /// 测试登录偏好只恢复手机号和协议状态，不恢复密码。
    func testApplyPreferencesRestoresUsernameAndPrivacyOnly() {
        let viewModel = LoginViewModel()
        viewModel.password = "manual-password"

        viewModel.applyPreferences(
            LoginPreferences(
                lastUsername: "18651857230",
                privacyAgreementAccepted: true
            )
        )

        XCTAssertEqual(viewModel.username, "18651857230")
        XCTAssertTrue(viewModel.privacyChecked)
        XCTAssertEqual(viewModel.password, "manual-password")
    }

    /// 测试已有手机号时不会被偏好覆盖。
    func testApplyPreferencesDoesNotOverwriteExistingUsername() {
        let viewModel = LoginViewModel()
        viewModel.username = "13900139000"

        viewModel.applyPreferences(
            LoginPreferences(
                lastUsername: "18651857230",
                privacyAgreementAccepted: false
            )
        )

        XCTAssertEqual(viewModel.username, "13900139000")
        XCTAssertFalse(viewModel.privacyChecked)
    }

    /// 测试用户同意协议后会勾选协议并关闭弹窗。
    func testAcceptAgreementChecksPrivacyAndDismissesSheet() {
        let viewModel = LoginViewModel()
        viewModel.showsAgreementSheet = true

        viewModel.acceptAgreement()

        XCTAssertTrue(viewModel.privacyChecked)
        XCTAssertFalse(viewModel.showsAgreementSheet)
    }

    /// 测试单账号登录会自动调用 set-user 并返回 completed。
    func testLoginWithSingleAccountAutoCompletesSetUser() async throws {
        let session = LoginRecordingURLSession(responses: [
            try TestFixture.data(named: "v9_login_single_scenic_success"),
            try TestFixture.data(named: "v9_set_scenic_user_success")
        ])
        let api = AuthAPI(client: APIClient(session: session))
        let viewModel = LoginViewModel()
        viewModel.username = "18651857230"
        viewModel.password = "secret123"
        viewModel.privacyChecked = true

        let resolution = try await viewModel.login(authAPI: api)

        guard case .completed(let response) = resolution else {
            return XCTFail("单账号登录应直接完成")
        }
        XCTAssertEqual(response.token, "business-token-scenic")
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertNil(viewModel.pendingAccountSelection)
        XCTAssertEqual(session.requests.map { $0.url?.path }, [
            "/api/app/v9/login",
            "/api/app/v9/set-user"
        ])
        let loginBody = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[0].httpBody)) as? [String: Any])
        XCTAssertEqual(loginBody["username"] as? String, "18651857230")
        let setUserBody = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[1].httpBody)) as? [String: Any])
        XCTAssertEqual(setUserBody["ss_user_id"] as? Int, 101)
        XCTAssertNil(setUserBody["store_user_id"] as? Int)
    }

    /// 测试多账号登录会保留待选账号并返回 needsAccountSelection。
    func testLoginWithMultipleAccountsNeedsSelection() async throws {
        let session = LoginRecordingURLSession(responses: [
            try TestFixture.data(named: "v9_login_multi_success")
        ])
        let api = AuthAPI(client: APIClient(session: session))
        let viewModel = LoginViewModel()
        viewModel.username = "18651857230"
        viewModel.password = "secret123"
        viewModel.privacyChecked = true

        let resolution = try await viewModel.login(authAPI: api)

        guard case .needsAccountSelection(let payload) = resolution else {
            return XCTFail("多账号登录应进入账号选择")
        }
        XCTAssertEqual(payload.tempToken, "person-temp-token")
        XCTAssertEqual(payload.accounts.map(\.businessUserId), [101, 201])
        XCTAssertEqual(viewModel.pendingAccountSelection, payload)
        XCTAssertEqual(session.requests.count, 1)
        XCTAssertEqual(session.requests.first?.url?.path, "/api/app/v9/login")
    }

    /// 测试没有可用账号时抛出 noAvailableAccount。
    func testLoginThrowsWhenNoAvailableAccounts() async throws {
        let session = LoginRecordingURLSession(responses: [
            """
            {
              "code": 100000,
              "msg": "success",
              "data": {
                "token": "person-temp-token",
                "scenic_users": [],
                "store_users": []
              }
            }
            """.data(using: .utf8)!
        ])
        let api = AuthAPI(client: APIClient(session: session))
        let viewModel = LoginViewModel()
        viewModel.username = "18651857230"
        viewModel.password = "secret123"
        viewModel.privacyChecked = true

        do {
            _ = try await viewModel.login(authAPI: api)
            XCTFail("没有可用账号时应抛出错误")
        } catch let error as LoginFlowError {
            guard case .noAvailableAccount = error else {
                return XCTFail("期望 noAvailableAccount，实际为 \(error)")
            }
        }
        XCTAssertFalse(viewModel.isLoading)
    }

    /// 测试登录响应缺少 token 时抛出 missingToken。
    func testLoginThrowsWhenTokenMissing() async throws {
        let session = LoginRecordingURLSession(responses: [
            """
            {
              "code": 100000,
              "msg": "success",
              "data": {
                "token": "",
                "scenic_users": [
                  {
                    "account_type": "scenic_user",
                    "id": 101,
                    "user_id": 101,
                    "scenic_user_id": 101,
                    "ss_user_id": 101,
                    "username": "scenic_admin",
                    "real_name": "张三",
                    "nickname": "张三",
                    "phone": "13800138000",
                    "scenic_id": 10,
                    "scenic_name": "示例景区",
                    "is_current": false
                  }
                ],
                "store_users": []
              }
            }
            """.data(using: .utf8)!
        ])
        let api = AuthAPI(client: APIClient(session: session))
        let viewModel = LoginViewModel()
        viewModel.username = "18651857230"
        viewModel.password = "secret123"
        viewModel.privacyChecked = true

        do {
            _ = try await viewModel.login(authAPI: api)
            XCTFail("缺少 token 时应抛出错误")
        } catch let error as LoginFlowError {
            guard case .missingToken = error else {
                return XCTFail("期望 missingToken，实际为 \(error)")
            }
        }
    }

    /// 测试重复发起登录时会抛出 CancellationError。
    func testLoginWhileLoadingThrowsCancellationError() async throws {
        let session = LoginRecordingURLSession(responses: [
            try TestFixture.data(named: "v9_login_single_scenic_success"),
            try TestFixture.data(named: "v9_set_scenic_user_success")
        ])
        let api = AuthAPI(client: APIClient(session: session))
        let viewModel = LoginViewModel()
        viewModel.username = "18651857230"
        viewModel.password = "secret123"
        viewModel.privacyChecked = true
        viewModel.isLoading = true

        do {
            _ = try await viewModel.login(authAPI: api)
            XCTFail("加载中重复登录应被取消")
        } catch {
            XCTAssertTrue(error is CancellationError)
        }
    }

    /// 测试选择账号后会用临时 token 调用 set-user 并清空待选状态。
    func testSelectAccountCompletesWithFinalToken() async throws {
        let session = LoginRecordingURLSession(responses: [
            try TestFixture.data(named: "v9_set_store_user_success")
        ])
        let api = AuthAPI(client: APIClient(session: session))
        let account = AccountSwitchAccount(
            accountType: V9StoreUser.accountTypeValue,
            businessUserId: 201,
            title: "示例门店",
            subtitle: "示例景区",
            phone: "13800138000",
            avatar: "",
            scenicName: "示例景区",
            storeId: 20,
            storeName: "示例门店",
            scenicId: 10,
            isCurrent: false
        )
        let viewModel = LoginViewModel()
        viewModel.pendingAccountSelection = AccountSelectionPayload(
            tempToken: "person-temp-token",
            accounts: [account]
        )

        let response = try await viewModel.selectAccount(account, authAPI: api)

        XCTAssertEqual(response.token, "business-token-store")
        XCTAssertNil(viewModel.pendingAccountSelection)
        XCTAssertFalse(viewModel.isSelectingAccount)
        let request = try XCTUnwrap(session.requests.first)
        XCTAssertEqual(request.url?.path, "/api/app/v9/set-user")
        XCTAssertEqual(request.value(forHTTPHeaderField: "token"), "person-temp-token")
        let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any])
        XCTAssertEqual(json["store_user_id"] as? Int, 201)
    }

    /// 测试缺少待选账号时 selectAccount 会抛出 missingToken。
    func testSelectAccountThrowsWhenPendingSelectionMissing() async {
        let api = AuthAPI(client: APIClient(session: LoginRecordingURLSession(responses: [])))
        let account = AccountSwitchAccount(
            accountType: V9StoreUser.accountTypeValue,
            businessUserId: 201,
            title: "示例门店",
            subtitle: "示例景区",
            phone: "13800138000",
            avatar: "",
            scenicName: "示例景区",
            storeId: 20,
            storeName: "示例门店",
            scenicId: 10,
            isCurrent: false
        )
        let viewModel = LoginViewModel()

        do {
            _ = try await viewModel.selectAccount(account, authAPI: api)
            XCTFail("缺少待选账号时应抛出错误")
        } catch let error as LoginFlowError {
            guard case .missingToken = error else {
                return XCTFail("期望 missingToken，实际为 \(error)")
            }
        } catch {
            XCTFail("意外错误: \(error)")
        }
    }

    /// 测试无效业务账号 ID 时 selectAccount 会抛出 invalidAccount。
    func testSelectAccountThrowsWhenAccountInvalid() async {
        let api = AuthAPI(client: APIClient(session: LoginRecordingURLSession(responses: [])))
        let account = AccountSwitchAccount(
            accountType: V9StoreUser.accountTypeValue,
            businessUserId: 0,
            title: "无效账号",
            subtitle: "",
            phone: "",
            avatar: "",
            scenicName: "",
            storeId: nil,
            storeName: "",
            scenicId: nil,
            isCurrent: false
        )
        let viewModel = LoginViewModel()
        viewModel.pendingAccountSelection = AccountSelectionPayload(
            tempToken: "person-temp-token",
            accounts: [account]
        )

        do {
            _ = try await viewModel.selectAccount(account, authAPI: api)
            XCTFail("无效账号 ID 时应抛出错误")
        } catch let error as LoginFlowError {
            guard case .invalidAccount = error else {
                return XCTFail("期望 invalidAccount，实际为 \(error)")
            }
        } catch {
            XCTFail("意外错误: \(error)")
        }
    }

    /// 测试取消账号选择时会清空待选状态。
    func testClearPendingAccountSelection() {
        let viewModel = LoginViewModel()
        viewModel.pendingAccountSelection = AccountSelectionPayload(
            tempToken: "person-temp-token",
            accounts: []
        )

        viewModel.clearPendingAccountSelection()

        XCTAssertNil(viewModel.pendingAccountSelection)
    }
}

/// 登录测试用 URLSession 替身，按顺序返回预设响应。
private final class LoginRecordingURLSession: URLSessionProtocol {
    private var responses: [Data]
    private(set) var requests: [URLRequest] = []

    /// 初始化顺序响应。
    init(responses: [Data]) {
        self.responses = responses
    }

    /// 记录请求并返回下一份响应。
    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        requests.append(request)
        let data = responses.isEmpty ? Data() : responses.removeFirst()
        return (
            data,
            HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
        )
    }
}
