//
//  KickoffAPI.swift — KickoffAPI's official lightweight Swift client (iOS 15+, macOS 12+).
//
//      let api = KickoffAPI(apiKey: "YOUR_API_KEY")   // free key: https://kickoffapi.com/portal/
//      let live = try await api.live_scores()
//      let epl  = try await api.standings(["league": "lg_8K3mZ0pQ", "season": 2025])
//
//  One file, Foundation's URLSession, no Swift Package Manager or CocoaPods
//  dependency to add: drop this file into your target.
//
//  Every method is `async throws` and returns the decoded JSON (Foundation's
//  JSONSerialization dictionary/array) as `Any`. For typed access, use the
//  `decoding:` overload with your own Codable model — see README.md.
//
//  Errors: every non-2xx response throws `KickoffAPIError`, which carries the
//  HTTP status, the message the API sent back, and the seconds from
//  `Retry-After` when the response was a 429.
//
//  Method names are the Python client's snake_case names, so the same call
//  reads the same in every official SDK, rather than Swift camelCase.
//
//  Spec: /openapi-v2.yaml
//

import Foundation

/// Every non-2xx response. `status` is the HTTP code, `message` the body the API
/// sent back, and `retryAfter` the seconds from the `Retry-After` header when
/// the response was a 429.
public struct KickoffAPIError: Error, LocalizedError, CustomStringConvertible {
    public let status: Int
    public let message: String
    public let retryAfter: Int?

    public init(status: Int, message: String, retryAfter: Int? = nil) {
        self.status = status
        self.message = message
        self.retryAfter = retryAfter
    }

    public var description: String { "KickoffAPI \(status): \(message)" }
    public var errorDescription: String? { message }
}

public final class KickoffAPI {
    /// Base URL every method is relative to.
    public static let defaultBaseURL = URL(string: "https://api.kickoffapi.com/api/v2")!

    /// Seconds before a request is abandoned.
    public static let defaultTimeout: TimeInterval = 30

    public let baseURL: URL
    private let apiKey: String
    private let session: URLSession
    private let timeout: TimeInterval

    public init(
        apiKey: String,
        baseURL: URL = KickoffAPI.defaultBaseURL,
        session: URLSession = .shared,
        timeout: TimeInterval = KickoffAPI.defaultTimeout
    ) {
        // A key-less client would 401 on every call, which reads like an API
        // outage. Fail here instead, where the mistake is.
        precondition(!apiKey.isEmpty, "KickoffAPI: api_key is required")
        self.apiKey = apiKey
        self.baseURL = baseURL
        self.session = session
        self.timeout = timeout
    }

    // MARK: - reference

    /// GET /countries — p: limit, cursor
    public func countries(_ p: [String: Any] = [:]) async throws -> Any { try await get("/countries", p) }

    /// GET /leagues — p: country, limit, cursor
    public func leagues(_ p: [String: Any] = [:]) async throws -> Any { try await get("/leagues", p) }

    /// GET /teams — p: country, limit, cursor
    public func teams(_ p: [String: Any] = [:]) async throws -> Any { try await get("/teams", p) }

    /// GET /bookmakers
    public func bookmakers() async throws -> Any { try await get("/bookmakers") }

    /// GET /bet-types
    public func bet_types() async throws -> Any { try await get("/bet-types") }

    /// GET /venues — p: search, limit, cursor
    public func venues(_ p: [String: Any] = [:]) async throws -> Any { try await get("/venues", p) }

    /// GET /leagues/{id}/source — which pipeline currently serves a league
    public func league_source(id: String) async throws -> Any { try await get("/leagues/\(id)/source") }

    /// GET /idmap/leagues — p: id (legacy id or comma-separated list), limit
    public func league_id_map(_ p: [String: Any] = [:]) async throws -> Any { try await get("/idmap/leagues", p) }

    /// GET /resolve — p: entity (league|team|fixture|player), legacyId
    public func resolve(_ p: [String: Any] = [:]) async throws -> Any { try await get("/resolve", p) }

    // MARK: - fixtures

    /// GET /fixtures — p: league, season, date, from, to, team, limit, cursor
    public func fixtures(_ p: [String: Any] = [:]) async throws -> Any { try await get("/fixtures", p) }

    /// GET /fixtures/{id} — detail, with any stored preview/recap embedded
    public func fixture(id: String) async throws -> Any { try await get("/fixtures/\(id)") }

    /// GET /fixtures/{id}/events — p: refresh
    public func fixture_events(id: String, _ p: [String: Any] = [:]) async throws -> Any {
        try await get("/fixtures/\(id)/events", p)
    }

    /// GET /fixtures/{id}/lineups
    public func fixture_lineups(id: String) async throws -> Any { try await get("/fixtures/\(id)/lineups") }

    /// GET /fixtures/{id}/statistics
    public func fixture_statistics(id: String) async throws -> Any { try await get("/fixtures/\(id)/statistics") }

    /// GET /fixtures/{id}/players
    public func fixture_players(id: String) async throws -> Any { try await get("/fixtures/\(id)/players") }

    /// GET /fixtures/{id}/projection
    public func fixture_projection(id: String) async throws -> Any { try await get("/fixtures/\(id)/projection") }

    /// GET /fixtures/{id}/model-odds — p: margin
    public func fixture_model_odds(id: String, _ p: [String: Any] = [:]) async throws -> Any {
        try await get("/fixtures/\(id)/model-odds", p)
    }

    /// GET /fixtures/{id}/preview — p: model, refresh, debug
    public func fixture_preview(id: String, _ p: [String: Any] = [:]) async throws -> Any {
        try await get("/fixtures/\(id)/preview", p)
    }

    /// GET /fixtures/{id}/content — stored preview and recap. A DB read.
    public func fixture_content(id: String) async throws -> Any { try await get("/fixtures/\(id)/content") }

    /// GET /headtohead — p: team1, team2, h2h
    public func head_to_head(_ p: [String: Any] = [:]) async throws -> Any { try await get("/headtohead", p) }

    // MARK: - tables, squads and leaderboards

    /// GET /standings — p: league, season (both required)
    public func standings(_ p: [String: Any] = [:]) async throws -> Any { try await get("/standings", p) }

    /// GET /players — p: limit, cursor
    public func players(_ p: [String: Any] = [:]) async throws -> Any { try await get("/players", p) }

    /// GET /teams/{id}/squad — p: season
    public func team_squad(id: String, _ p: [String: Any] = [:]) async throws -> Any {
        try await get("/teams/\(id)/squad", p)
    }

    /// GET /teams/{id}/form — p: season (required), n
    public func team_form(id: String, _ p: [String: Any] = [:]) async throws -> Any {
        try await get("/teams/\(id)/form", p)
    }

    /// GET /topscorers — p: league, season
    public func top_scorers(_ p: [String: Any] = [:]) async throws -> Any { try await get("/topscorers", p) }

    /// GET /topassists — p: league, season
    public func top_assists(_ p: [String: Any] = [:]) async throws -> Any { try await get("/topassists", p) }

    /// GET /insights/season-sim — p: league, season, sims, cutoff
    public func season_simulation(_ p: [String: Any] = [:]) async throws -> Any {
        try await get("/insights/season-sim", p)
    }

    /// GET /insights/strength — p: league, season
    public func team_strength(_ p: [String: Any] = [:]) async throws -> Any { try await get("/insights/strength", p) }

    // MARK: - odds, transfers, injuries, honours

    /// GET /odds — p: fixture (required), bookmaker, bet
    public func odds(_ p: [String: Any] = [:]) async throws -> Any { try await get("/odds", p) }

    /// GET /odds/live — p: limit, cursor
    public func live_odds(_ p: [String: Any] = [:]) async throws -> Any { try await get("/odds/live", p) }

    /// GET /transfers — p: player, team, limit, cursor
    public func transfers(_ p: [String: Any] = [:]) async throws -> Any { try await get("/transfers", p) }

    /// GET /injuries — p: fixture, team, player, limit, cursor
    public func injuries(_ p: [String: Any] = [:]) async throws -> Any { try await get("/injuries", p) }

    /// GET /coaches — p: team, limit, cursor
    public func coaches(_ p: [String: Any] = [:]) async throws -> Any { try await get("/coaches", p) }

    /// GET /trophies — p: player, limit, cursor
    public func trophies(_ p: [String: Any] = [:]) async throws -> Any { try await get("/trophies", p) }

    // MARK: - in-house models

    /// GET /xg — p: x, y (required), bodyPart, situation
    public func xg(_ p: [String: Any] = [:]) async throws -> Any { try await get("/xg", p) }

    /// POST /xg — each shot carries x, y, bodyPart, situation
    public func xg_batch(shots: [[String: Any]]) async throws -> Any {
        try await post("/xg", body: ["shots": shots])
    }

    /// GET /xg/model
    public func xg_model() async throws -> Any { try await get("/xg/model") }

    // MARK: - account

    /// GET /account/status
    public func account_status() async throws -> Any { try await get("/account/status") }

    /// GET /account/overage
    public func account_overage() async throws -> Any { try await get("/account/overage") }

    /// POST /account/export — the GDPR export of the authenticated account
    public func export_my_data() async throws -> Any { try await post("/account/export", body: [:]) }

    /// POST /account/delete-request — emails the token. Destructive once confirmed.
    public func request_account_deletion() async throws -> Any {
        try await post("/account/delete-request", body: [:])
    }

    /// GET /account/delete-confirm — token from the deletion email. Destructive.
    public func confirm_account_deletion(token: String) async throws -> Any {
        try await get("/account/delete-confirm", ["token": token])
    }

    // MARK: - kept for v1 parity (all live on /api/v2 as well)

    /// GET /fixtures?live=all
    public func live_scores() async throws -> Any { try await get("/fixtures", ["live": "all"]) }

    /// v2's Elo prediction, not v1's /predictions — /api/v2/predictions is not a route.
    /// p: team1, team2
    public func predictions(_ p: [String: Any] = [:]) async throws -> Any {
        try await get("/predictions/elo", p)
    }

    /// GET /teams/{id}/statistics — p: league, season
    public func team_statistics(id: String, _ p: [String: Any] = [:]) async throws -> Any {
        try await get("/teams/\(id)/statistics", p)
    }

    // --- events, markets, insights -------------------------------------------

    /// GET /events — p: fixture (required)
    public func events(_ p: [String: Any] = [:]) async throws -> Any { try await get("/events", p) }

    /// GET /fixtures/{id}/markets — derived markets for a fixture
    public func fixture_markets(id: String) async throws -> Any { try await get("/fixtures/\(id)/markets") }

    /// GET /insights/accuracy — p: league, season
    public func model_accuracy(_ p: [String: Any] = [:]) async throws -> Any { try await get("/insights/accuracy", p) }

    /// GET /insights/fixture — p: fixture (required)
    public func fixture_insight(_ p: [String: Any] = [:]) async throws -> Any { try await get("/insights/fixture", p) }

    // --- league form guide, over/under -----------------------------------------

    /// GET /leagues/{id}/form-guide — p: season (required)
    public func league_form_guide(id: String, _ p: [String: Any] = [:]) async throws -> Any {
        try await get("/leagues/\(id)/form-guide", p)
    }

    /// GET /leagues/{id}/over-under — p: season (required)
    public func league_over_under(id: String, _ p: [String: Any] = [:]) async throws -> Any {
        try await get("/leagues/\(id)/over-under", p)
    }

    // --- player career & predictions -----------------------------------------

    /// GET /players/{id}/career — player career and club history
    public func player_career(id: String) async throws -> Any { try await get("/players/\(id)/career") }

    /// GET /players/{id}/predictions — p: league, season, opponent
    public func player_predictions(id: String, _ p: [String: Any] = [:]) async throws -> Any {
        try await get("/players/\(id)/predictions", p)
    }

    // --- sidelined, team rankings, results, streaks --------------------------

    /// GET /sidelined — p: player, team, fixture, limit, cursor
    public func sidelined(_ p: [String: Any] = [:]) async throws -> Any { try await get("/sidelined", p) }

    /// GET /teams/rankings — p: country, limit
    public func team_rankings(_ p: [String: Any] = [:]) async throws -> Any { try await get("/teams/rankings", p) }

    /// GET /teams/{id}/results — p: n
    public func team_results(id: String, _ p: [String: Any] = [:]) async throws -> Any {
        try await get("/teams/\(id)/results", p)
    }

    /// GET /teams/{id}/streaks — win, draw and scoring streaks
    public func team_streaks(id: String) async throws -> Any { try await get("/teams/\(id)/streaks") }

    // --- timezone, leaderboards, value bets ----------------------------------

    /// GET /timezone — IANA time zone identifiers
    public func timezone() async throws -> Any { try await get("/timezone") }

    /// GET /topredcards — p: league, season, limit, cursor
    public func top_red_cards(_ p: [String: Any] = [:]) async throws -> Any { try await get("/topredcards", p) }

    /// GET /topyellowcards — p: league, season, limit, cursor
    public func top_yellow_cards(_ p: [String: Any] = [:]) async throws -> Any { try await get("/topyellowcards", p) }

    /// GET /value-bets — p: league, season
    public func value_bets(_ p: [String: Any] = [:]) async throws -> Any { try await get("/value-bets", p) }

    // MARK: - transport

    /// GET a v2 path. Escape hatch for anything without a named method yet.
    @discardableResult
    public func get(_ path: String, _ params: [String: Any] = [:]) async throws -> Any {
        try await send("GET", path, params: params, body: nil)
    }

    /// GET a v2 path and decode it into your own Codable model.
    public func get<T: Decodable>(_ path: String, _ params: [String: Any] = [:], decoding type: T.Type) async throws -> T {
        let decoded = try await get(path, params)
        let json = try JSONSerialization.data(withJSONObject: decoded, options: [.fragmentsAllowed])
        return try JSONDecoder().decode(T.self, from: json)
    }

    /// POST a JSON body to a v2 path.
    @discardableResult
    public func post(_ path: String, body: [String: Any]? = nil, params: [String: Any] = [:]) async throws -> Any {
        try await send("POST", path, params: params, body: body)
    }

    /// POST a JSON body and decode the answer into your own Codable model.
    public func post<T: Decodable>(_ path: String, body: [String: Any]? = nil, decoding type: T.Type) async throws -> T {
        let decoded = try await post(path, body: body)
        let json = try JSONSerialization.data(withJSONObject: decoded, options: [.fragmentsAllowed])
        return try JSONDecoder().decode(T.self, from: json)
    }

    private func send(_ method: String, _ path: String, params: [String: Any], body: [String: Any]?) async throws -> Any {
        let url = try makeURL(path, params)
        var request = URLRequest(url: url)
        request.httpMethod = method
        request.timeoutInterval = timeout
        request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        if let body = body {
            request.httpBody = try JSONSerialization.data(withJSONObject: body)
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        }

        let (data, response) = try await session.data(for: request)
        let http = response as? HTTPURLResponse
        let status = http?.statusCode ?? 0
        let retryAfter = Int((http?.value(forHTTPHeaderField: "Retry-After") ?? "").trimmingCharacters(in: .whitespaces))
        let decoded: Any? = data.isEmpty ? nil : try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])

        guard (200..<300).contains(status) else {
            let message = (decoded as? [String: Any])?["error"] as? String
                ?? String(data: data, encoding: .utf8)
                ?? "request failed"
            throw KickoffAPIError(status: status, message: message, retryAfter: retryAfter)
        }
        guard let decoded = decoded else {
            throw KickoffAPIError(
                status: status,
                message: "response was not JSON: " + (String(data: data.prefix(200), encoding: .utf8) ?? ""),
                retryAfter: retryAfter
            )
        }
        return decoded
    }

    private func makeURL(_ path: String, _ params: [String: Any]) throws -> URL {
        guard var components = URLComponents(string: baseURL.absoluteString + path) else {
            throw KickoffAPIError(status: 0, message: "KickoffAPI: could not build a URL for \(path)")
        }
        let items = params
            .sorted { $0.key < $1.key } // stable order: a URL is easier to read and to log
            .map { URLQueryItem(name: $0.key, value: String(describing: $0.value)) }
        if !items.isEmpty { components.queryItems = items }
        guard let url = components.url else {
            throw KickoffAPIError(status: 0, message: "KickoffAPI: could not build a URL for \(path)")
        }
        return url
    }
}
