//
//  InstanceDetector.swift
//  Yattee
//
//  Automatically detects backend instance type by probing API endpoints.
//

import Foundation

/// Errors that can occur during instance detection.
enum DetectionError: Error, Sendable {
    case sslCertificateError
    case networkError(String)
    case unknownType
    case invalidURL
    case timeout
    /// The instance is fronted by an HTTP Basic Auth challenge (401). The user must
    /// supply credentials before detection can identify the backend type.
    case basicAuthRequired
    /// Detection was retried with HTTP Basic Auth credentials but the server still
    /// returned 401 — the credentials are invalid.
    case basicAuthInvalid
    /// The URL points to the Piped web frontend SPA, not its API backend. The
    /// frontend serves the same `index.html` for every path, so JSON probes can
    /// never succeed — the user must supply the API URL instead.
    case pipedFrontendDetected

    var localizedDescription: String {
        switch self {
        case .sslCertificateError:
            return String(localized: "sources.error.sslCertificate")
        case .networkError(let message):
            return message
        case .unknownType:
            return String(localized: "sources.error.couldNotDetect")
        case .invalidURL:
            return String(localized: "sources.validation.invalidURL")
        case .timeout:
            return String(localized: "sources.error.timeout")
        case .basicAuthRequired:
            return String(localized: "sources.error.basicAuthRequired")
        case .basicAuthInvalid:
            return String(localized: "sources.error.basicAuthInvalid")
        case .pipedFrontendDetected:
            return String(localized: "sources.error.pipedFrontendDetected")
        }
    }
}

/// Result of instance detection including type and authentication requirements.
struct InstanceDetectionResult: Sendable {
    let type: InstanceType
    /// Whether this instance requires authentication (Basic Auth for Yattee Server).
    let requiresAuth: Bool

    init(type: InstanceType, requiresAuth: Bool = false) {
        self.type = type
        self.requiresAuth = requiresAuth
    }
}

/// Detects the type of a backend instance by probing known API endpoints.
actor InstanceDetector {
    private let httpClient: HTTPClient

    init(httpClient: HTTPClient) {
        self.httpClient = httpClient
    }

    /// Detects the instance type for a given URL.
    /// - Parameter url: The base URL of the instance.
    /// - Returns: The detected instance type, or nil if detection failed.
    func detect(url: URL) async -> InstanceType? {
        let result = await detectWithAuth(url: url)
        return result?.type
    }

    /// Detects the instance type and authentication requirements for a given URL.
    /// - Parameter url: The base URL of the instance.
    /// - Returns: The detection result including type and auth requirements, or nil if detection failed.
    func detectWithAuth(url: URL) async -> InstanceDetectionResult? {
        let result = await detectWithResult(url: url)
        switch result {
        case .success(let detectionResult):
            return detectionResult
        case .failure:
            return nil
        }
    }

    /// Detects the instance type with detailed error reporting.
    /// - Parameters:
    ///   - url: The base URL of the instance.
    ///   - basicAuthHeader: Optional HTTP Basic Auth header value (e.g., "Basic dXNlcjpwYXNz")
    ///     to inject into every probe. Used to retry detection after the user provides
    ///     credentials for an instance fronted by a reverse proxy.
    /// - Returns: Result containing either the detection result or a detailed error.
    func detectWithResult(
        url: URL,
        basicAuthHeader: String? = nil
    ) async -> Result<InstanceDetectionResult, DetectionError> {
        let extraHeaders: [String: String]? = basicAuthHeader.map { ["Authorization": $0] }

        // A 401 from a single probe is *not* enough to conclude that credentials are
        // invalid. Some probe paths (e.g. Yattee Server's `/info`) trigger an HTTP
        // redirect on Invidious, and iOS URLSession strips the Authorization header
        // when following redirects, so the redirected request 401s even when the
        // credentials are valid. We instead consider credentials bad only if EVERY
        // probe failed with 401 and none matched.
        var sawUnauthorized = false

        // Try each detection method in order of specificity.
        // Check Yattee Server first as it's most specific.
        do {
            if let result = try await detectYatteeServerWithError(url: url, extraHeaders: extraHeaders) {
                return .success(result)
            }
        } catch let error as DetectionError {
            return .failure(error)
        } catch APIError.unauthorized {
            sawUnauthorized = true
        } catch {
            // Continue to next detection method
        }

        do {
            if try await isPeerTube(url: url, extraHeaders: extraHeaders) {
                return .success(InstanceDetectionResult(type: .peertube))
            }
        } catch APIError.unauthorized {
            sawUnauthorized = true
        } catch {
            // Continue to next detection method
        }

        do {
            if try await isInvidious(url: url, extraHeaders: extraHeaders) {
                return .success(InstanceDetectionResult(type: .invidious))
            }
        } catch APIError.unauthorized {
            sawUnauthorized = true
        } catch {
            // Continue to next detection method
        }

        do {
            if try await isPiped(url: url, extraHeaders: extraHeaders) {
                return .success(InstanceDetectionResult(type: .piped))
            }
        } catch APIError.unauthorized {
            sawUnauthorized = true
        } catch {
            // Fall through
        }

        // No probe matched. If at least one probe returned 401, the instance is
        // (or appears to be) behind HTTP Basic Auth. Distinguish "needs creds" from
        // "creds rejected" by whether the caller already supplied a header.
        if sawUnauthorized {
            return .failure(basicAuthHeader == nil ? .basicAuthRequired : .basicAuthInvalid)
        }

        if await looksLikePipedFrontend(url: url, extraHeaders: extraHeaders) {
            return .failure(.pipedFrontendDetected)
        }

        return .failure(.unknownType)
    }

    /// Detects the Piped Vue SPA by fetching `/` and looking for the
    /// `<title>Piped</title>` fingerprint. The frontend serves the same
    /// `index.html` for every path with HTTP 200, so a body-content check is
    /// the cheapest reliable signal.
    private func looksLikePipedFrontend(url: URL, extraHeaders: [String: String]? = nil) async -> Bool {
        let endpoint = GenericEndpoint.get("/")
        do {
            let data = try await httpClient.fetchData(endpoint, baseURL: url, customHeaders: extraHeaders)
            guard let body = String(data: data, encoding: .utf8) else { return false }
            let lower = body.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
            let looksLikeHTML = lower.hasPrefix("<!doctype html") || lower.hasPrefix("<html")
            let hasPipedTitle = lower.contains("<title>piped</title>")
            return looksLikeHTML && hasPipedTitle
        } catch {
            return false
        }
    }

    // MARK: - Detection Methods

    /// Detects if the instance is a Yattee Server with detailed error reporting.
    /// Throws `APIError.unauthorized` if the probe receives a 401, so the caller can prompt for credentials.
    private func detectYatteeServerWithError(
        url: URL,
        extraHeaders: [String: String]? = nil
    ) async throws -> InstanceDetectionResult? {
        let endpoint = GenericEndpoint.get("/info")

        do {
            // First, get raw data to debug the response
            let rawData = try await httpClient.fetchData(endpoint, baseURL: url, customHeaders: extraHeaders)
            if let rawString = String(data: rawData, encoding: .utf8) {
                LoggingService.shared.debug("[InstanceDetector] Raw /info response: \(rawString)", category: .api)
            }

            let response = try JSONDecoder().decode(InstanceDetectorModels.YatteeServerInfo.self, from: rawData)
            LoggingService.shared.debug("[InstanceDetector] Parsed YatteeServerInfo: name=\(response.name ?? "nil")", category: .api)

            // Yattee Server returns name containing "yattee"
            if response.name?.lowercased().contains("yattee") == true {
                // Auth is always required for Yattee Server
                let result = InstanceDetectionResult(
                    type: .yatteeServer,
                    requiresAuth: true
                )
                LoggingService.shared.debug("[InstanceDetector] Returning result: type=yatteeServer, requiresAuth=true", category: .api)
                return result
            }
            return nil
        } catch APIError.unauthorized {
            LoggingService.shared.debug("[InstanceDetector] /info returned 401 — basic auth required", category: .api)
            throw APIError.unauthorized
        } catch let urlError as URLError {
            LoggingService.shared.error("[InstanceDetector] detectYatteeServer URLError", category: .api, details: urlError.localizedDescription)
            // Check for SSL certificate errors
            if urlError.code == .serverCertificateUntrusted ||
               urlError.code == .serverCertificateHasBadDate ||
               urlError.code == .serverCertificateHasUnknownRoot ||
               urlError.code == .serverCertificateNotYetValid ||
               urlError.code == .clientCertificateRejected {
                throw DetectionError.sslCertificateError
            }
            if urlError.code == .timedOut {
                throw DetectionError.timeout
            }
            throw DetectionError.networkError(urlError.localizedDescription)
        } catch {
            LoggingService.shared.error("[InstanceDetector] detectYatteeServer error", category: .api, details: error.localizedDescription)
            return nil
        }
    }

    /// Checks if the instance is PeerTube by calling /api/v1/config.
    /// Re-throws `APIError.unauthorized` so the caller can prompt for basic-auth credentials.
    private func isPeerTube(url: URL, extraHeaders: [String: String]? = nil) async throws -> Bool {
        let endpoint = GenericEndpoint.get("/api/v1/config")

        do {
            let response: InstanceDetectorModels.PeerTubeConfig = try await httpClient.fetch(endpoint, baseURL: url, customHeaders: extraHeaders)
            // PeerTube config has specific fields
            return response.instance != nil || response.serverVersion != nil
        } catch APIError.unauthorized {
            throw APIError.unauthorized
        } catch {
            return false
        }
    }

    /// Checks if the instance is Invidious by calling /api/v1/stats.
    /// Re-throws `APIError.unauthorized` so the caller can prompt for basic-auth credentials.
    private func isInvidious(url: URL, extraHeaders: [String: String]? = nil) async throws -> Bool {
        let endpoint = GenericEndpoint.get("/api/v1/stats")

        do {
            let response: InstanceDetectorModels.InvidiousStats = try await httpClient.fetch(endpoint, baseURL: url, customHeaders: extraHeaders)
            // Invidious stats has software.name = "invidious"
            return response.software?.name?.lowercased() == "invidious"
        } catch APIError.unauthorized {
            throw APIError.unauthorized
        } catch {
            return false
        }
    }

    /// Checks if the instance is Piped by probing Piped-specific endpoints.
    /// Re-throws `APIError.unauthorized` so the caller can prompt for basic-auth credentials.
    private func isPiped(url: URL, extraHeaders: [String: String]? = nil) async throws -> Bool {
        // Piped has a /healthcheck endpoint that returns "OK"
        let healthEndpoint = GenericEndpoint.get("/healthcheck")

        do {
            let data = try await httpClient.fetchData(healthEndpoint, baseURL: url, customHeaders: extraHeaders)
            if let text = String(data: data, encoding: .utf8), text.contains("OK") {
                return true
            }
        } catch APIError.unauthorized {
            throw APIError.unauthorized
        } catch {
            // Continue to next check
        }

        // Also try /config endpoint which Piped uses
        let configEndpoint = GenericEndpoint.get("/config")

        do {
            let response: InstanceDetectorModels.PipedConfig = try await httpClient.fetch(configEndpoint, baseURL: url, customHeaders: extraHeaders)
            // Piped config has specific fields
            return response.donationUrl != nil || response.statusPageUrl != nil
        } catch APIError.unauthorized {
            throw APIError.unauthorized
        } catch {
            return false
        }
    }
}

// MARK: - Detection Response Models

/// Namespace for instance detection response models.
/// Using an enum as a namespace to avoid MainActor isolation issues.
enum InstanceDetectorModels {
    struct YatteeServerInfo: Sendable {
        let name: String?
        let version: String?
        let description: String?
    }

    /// Full server info response from /info endpoint for display in UI.
    struct YatteeServerFullInfo: Sendable {
        let name: String?
        let version: String?
        let dependencies: Dependencies?
        let config: Config?
        let sites: [Site]?

        struct Dependencies: Sendable {
            let ytDlp: String?
            let ffmpeg: String?
        }

        struct Config: Sendable {
            let invidiousInstance: String?
            let allowAllSitesForExtraction: Bool?
        }

        struct Site: Sendable {
            let name: String
            let extractorPattern: String?
        }
    }

    struct PeerTubeConfig: Sendable {
        let instance: PeerTubeInstanceInfo?
        let serverVersion: String?

        struct PeerTubeInstanceInfo: Sendable {
            let name: String?
            let shortDescription: String?
        }
    }

    struct InvidiousStats: Sendable {
        let software: InvidiousSoftware?

        struct InvidiousSoftware: Sendable {
            let name: String?
            let version: String?
        }
    }

    struct PipedConfig: Sendable {
        let donationUrl: String?
        let statusPageUrl: String?
        let s3Enabled: Bool?
    }
}

// MARK: - Decodable Conformance (nonisolated)

extension InstanceDetectorModels.YatteeServerInfo: Decodable {
    private enum CodingKeys: String, CodingKey {
        case name, version, description
    }

    nonisolated init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decodeIfPresent(String.self, forKey: .name)
        version = try container.decodeIfPresent(String.self, forKey: .version)
        description = try container.decodeIfPresent(String.self, forKey: .description)
    }
}

extension InstanceDetectorModels.YatteeServerFullInfo: Decodable {
    private enum CodingKeys: String, CodingKey {
        case name, version, dependencies, config, sites
    }

    nonisolated init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decodeIfPresent(String.self, forKey: .name)
        version = try container.decodeIfPresent(String.self, forKey: .version)
        dependencies = try container.decodeIfPresent(Dependencies.self, forKey: .dependencies)
        config = try container.decodeIfPresent(Config.self, forKey: .config)
        sites = try container.decodeIfPresent([Site].self, forKey: .sites)
    }
}

extension InstanceDetectorModels.YatteeServerFullInfo.Dependencies: Decodable {
    private enum CodingKeys: String, CodingKey {
        case ytDlp = "yt-dlp"
        case ffmpeg
    }

    nonisolated init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        ytDlp = try container.decodeIfPresent(String.self, forKey: .ytDlp)
        ffmpeg = try container.decodeIfPresent(String.self, forKey: .ffmpeg)
    }
}

// Config and Site use automatic Decodable synthesis since HTTPClient uses .convertFromSnakeCase
extension InstanceDetectorModels.YatteeServerFullInfo.Config: Decodable {}
extension InstanceDetectorModels.YatteeServerFullInfo.Site: Decodable {}

extension InstanceDetectorModels.PeerTubeConfig: Decodable {
    private enum CodingKeys: String, CodingKey {
        case instance, serverVersion
    }

    nonisolated init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        instance = try container.decodeIfPresent(PeerTubeInstanceInfo.self, forKey: .instance)
        serverVersion = try container.decodeIfPresent(String.self, forKey: .serverVersion)
    }
}

extension InstanceDetectorModels.PeerTubeConfig.PeerTubeInstanceInfo: Decodable {
    private enum CodingKeys: String, CodingKey {
        case name, shortDescription
    }

    nonisolated init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decodeIfPresent(String.self, forKey: .name)
        shortDescription = try container.decodeIfPresent(String.self, forKey: .shortDescription)
    }
}

extension InstanceDetectorModels.InvidiousStats: Decodable {
    private enum CodingKeys: String, CodingKey {
        case software
    }

    nonisolated init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        software = try container.decodeIfPresent(InvidiousSoftware.self, forKey: .software)
    }
}

extension InstanceDetectorModels.InvidiousStats.InvidiousSoftware: Decodable {
    private enum CodingKeys: String, CodingKey {
        case name, version
    }

    nonisolated init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decodeIfPresent(String.self, forKey: .name)
        version = try container.decodeIfPresent(String.self, forKey: .version)
    }
}

extension InstanceDetectorModels.PipedConfig: Decodable {
    private enum CodingKeys: String, CodingKey {
        case donationUrl, statusPageUrl, s3Enabled
    }

    nonisolated init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        donationUrl = try container.decodeIfPresent(String.self, forKey: .donationUrl)
        statusPageUrl = try container.decodeIfPresent(String.self, forKey: .statusPageUrl)
        s3Enabled = try container.decodeIfPresent(Bool.self, forKey: .s3Enabled)
    }
}
