import Foundation
import NucleicProtocol

/// A provider whose public status page we watch, plus everything needed to fetch, parse,
/// and filter its feed: the feed URL and envelope (Atom or RSS), the feed's HTML dialect
/// (publishers encode an incident's current state differently), and the components we
/// surface.
///
/// Claude (Atlassian Statuspage) and OpenAI (incident.io) publish Atom; xAI publishes RSS.
/// `FeedParser` reads either envelope. None of the feeds tag incidents with machine-readable
/// component IDs — affected services appear only as prose (a title prefix or body text) — so
/// component matching is by name (see `WatchedComponent` and `matchText`).
public enum StatusProvider: String, Sendable, CaseIterable, Identifiable {
    case claude
    case openai
    case xai

    public var id: String { rawValue }

    /// Human label for the provider.
    public var displayName: String {
        switch self {
        case .claude: return "Claude"
        case .openai: return "OpenAI"
        case .xai: return "xAI"
        }
    }

    /// The feed URL (Atom for Claude/OpenAI, RSS for xAI).
    public var feedURL: URL {
        switch self {
        case .claude: return URL(string: "https://status.claude.com/history.atom")!
        case .openai: return URL(string: "https://status.openai.com/feed.atom")!
        case .xai: return URL(string: "https://status.x.ai/feed.xml")!
        }
    }

    /// The components we surface for this provider, each with the regex fragment that decides
    /// whether an incident touches it. Matched case-insensitively on word boundaries (`\b…\b`)
    /// against the incident's match text, so "API" fires on "Codex API" but not "capacity";
    /// `apis?` catches both "API" and "APIs".
    public var watched: [WatchedComponent] {
        switch self {
        case .claude:
            return [
                WatchedComponent(name: "Claude Code", pattern: "claude code"),
                WatchedComponent(name: "Claude API", pattern: "claude apis?"),
            ]
        case .openai:
            return [
                WatchedComponent(name: "Codex", pattern: "codex"),
                WatchedComponent(name: "APIs", pattern: "apis?"),
            ]
        case .xai:
            // xAI's build-relevant surface is the developer API (incl. the API Console, which
            // hosts "Grok Build"); "Grok" covers the Grok app. See `matchText`.
            return [
                WatchedComponent(name: "API", pattern: "apis?"),
                WatchedComponent(name: "Grok", pattern: "grok"),
            ]
        }
    }

    /// How this provider stamps an incident's current state into the entry body.
    var dialect: FeedDialect {
        switch self {
        case .claude: return .strongPrefixed   // newest update first, as `<strong>State</strong>`
        case .openai, .xai: return .statusLabeled   // body carries "Status: State"
        }
    }

    /// The text an incident is matched against to decide which watched components it touches.
    /// xAI tags each incident's component in a leading `[Component (region)]`, and its bodies
    /// mention model names like `grok-imagine`, so matching the bracket prefix alone keeps an
    /// API incident from being miscounted as a Grok-app one. Claude/OpenAI carry no such tag,
    /// so they match against the whole title + body.
    func matchText(title: String, body: String) -> String {
        switch self {
        case .xai: return Self.bracketPrefix(title) ?? title
        case .claude, .openai: return title + "\n" + body
        }
    }

    /// The contents of a leading `[...]` tag in a title, or nil if there isn't one.
    private static func bracketPrefix(_ title: String) -> String? {
        guard let open = title.firstIndex(of: "["),
              let close = title.firstIndex(of: "]"), open < close else { return nil }
        return String(title[title.index(after: open)..<close])
    }

    /// The provider that serves a model SKU, via its backend: Claude SKUs (`claude-*`) map to
    /// `.claude`, GPT/Codex SKUs (`gpt-*`, `o3*`, `o4*`, `*codex*`) to `.openai`, and Grok SKUs
    /// (`grok-*`) to `.xai`. Nil for an unrecognized SKU. Lets the status feeds drive model
    /// failover (see `ModelFailover`).
    public static func forModel(_ model: String?) -> StatusProvider? {
        switch BackendID.forModel(model) {
        case .claudeCode: return .claude
        case .codex, .codexExec: return .openai
        case .grok: return .xai
        case nil: return nil
        }
    }
}

/// A temporary, status-driven default-model switch: while `original`'s provider is reporting
/// an incident, new chats default to `alternative` on a healthy provider instead. Recorded so
/// the app can restore `original` automatically once its provider recovers (see
/// `AppStore.refreshStatusFeeds`) or let the user switch back early.
public struct ModelFailover: Sendable, Equatable {
    /// The user's preferred default model we switched away from.
    public let original: String
    /// The comparable model on another provider we switched to.
    public let alternative: String

    public init(original: String, alternative: String) {
        self.original = original
        self.alternative = alternative
    }
}

/// A watched service plus the pattern that maps incidents to it.
public struct WatchedComponent: Sendable, Equatable {
    /// Display name, e.g. "Claude API".
    public let name: String
    /// Case-insensitive regex fragment matched on word boundaries against an incident's match
    /// text (see `StatusProvider.matchText`). Defined in-source (trusted), so it's anchored
    /// with `\b…\b` at match time rather than escaped.
    let pattern: String

    public init(name: String, pattern: String) {
        self.name = name
        self.pattern = pattern
    }
}

/// How a feed's body encodes an incident's current state — the only place providers diverge
/// once the envelope is parsed. `strongPrefixed`: the newest update leads with
/// `<strong>State</strong>` (Statuspage). `statusLabeled`: the body carries "Status: State"
/// (incident.io and xAI).
enum FeedDialect { case strongPrefixed, statusLabeled }

/// One incident from a status feed, already mapped to the watched component(s) it affects.
public struct StatusIncident: Sendable, Equatable, Identifiable {
    /// The feed entry's stable id (used to de-dupe / diff across polls).
    public let id: String
    public let title: String
    /// Link to the incident's page, when the feed provides one.
    public let url: URL?
    /// When the incident was last updated (the entry's `<updated>`).
    public let updatedAt: Date?
    /// Current state word as published — "Investigating", "Monitoring", "Resolved", … — or
    /// nil if the body didn't carry one.
    public let state: String?
    /// Whether the incident is closed (its latest state is resolved/completed/postmortem).
    /// Active (`false`) incidents are what the indicator surfaces loudly.
    public let isResolved: Bool
    /// Watched components this incident matched — always non-empty (incidents matching no
    /// watched component are dropped during parsing).
    public let components: [String]

    public init(
        id: String, title: String, url: URL?, updatedAt: Date?,
        state: String?, isResolved: Bool, components: [String]
    ) {
        self.id = id
        self.title = title
        self.url = url
        self.updatedAt = updatedAt
        self.state = state
        self.isResolved = isResolved
        self.components = components
    }
}

/// A provider's feed reduced to the incidents that touch its watched components, newest
/// first. An empty `incidents` array means every watched component is operational (no
/// matching incident in the feed's window).
public struct StatusFeed: Sendable, Equatable {
    public let provider: StatusProvider
    /// Watched-component incidents, newest first.
    public let incidents: [StatusIncident]

    public init(provider: StatusProvider, incidents: [StatusIncident]) {
        self.provider = provider
        self.incidents = incidents
    }

    /// Unresolved incidents — the ones worth alerting on.
    public var activeIncidents: [StatusIncident] { incidents.filter { !$0.isResolved } }

    /// Whether any watched component currently has an unresolved incident.
    public var hasActiveIncident: Bool { incidents.contains { !$0.isResolved } }
}

public enum StatusFeedError: Error, Sendable {
    case badResponse
    case http(Int)
    /// The bytes weren't parseable as an Atom/RSS feed.
    case malformed
}

/// Fetches and parses a provider's status feed (Atom or RSS) into its watched-component
/// incidents. `nonisolated` and stateless: safe to call from the main actor — the network
/// await runs off-main.
public enum StatusFeedFetcher {
    /// Fetch and parse one provider's feed.
    public static func fetch(_ provider: StatusProvider) async throws -> StatusFeed {
        var request = URLRequest(url: provider.feedURL)
        request.timeoutInterval = 15
        request.httpMethod = "GET"
        request.setValue("application/atom+xml, application/rss+xml, application/xml;q=0.9",
                         forHTTPHeaderField: "Accept")

        let (data, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse else { throw StatusFeedError.badResponse }
        guard http.statusCode == 200 else { throw StatusFeedError.http(http.statusCode) }
        return try parse(data, provider: provider)
    }

    /// Parse an Atom feed's bytes into the provider's watched-component incidents, newest
    /// first. Separated from `fetch` so tests can exercise it against captured feeds.
    static func parse(_ data: Data, provider: StatusProvider) throws -> StatusFeed {
        let entries = try FeedParser.parse(data)
        // Build one boundary-anchored, case-insensitive matcher per watched component.
        let matchers: [(name: String, regex: NSRegularExpression)] = provider.watched.compactMap { comp in
            guard let re = try? NSRegularExpression(
                pattern: "\\b(?:\(comp.pattern))\\b", options: [.caseInsensitive]) else { return nil }
            return (comp.name, re)
        }

        var incidents: [StatusIncident] = []
        for entry in entries {
            let corpus = provider.matchText(title: entry.title, body: htmlToText(entry.content))
            let matched = matchers.compactMap { matcher -> String? in
                matches(matcher.regex, corpus) ? matcher.name : nil
            }
            guard !matched.isEmpty else { continue }   // no watched component — skip

            let state = currentState(of: entry.content, dialect: provider.dialect)
            incidents.append(StatusIncident(
                id: entry.id,
                title: entry.title,
                url: entry.link,
                updatedAt: parseDate(entry.updatedRaw),
                state: state,
                isResolved: isResolvedState(state),
                components: matched))
        }
        incidents.sort { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
        return StatusFeed(provider: provider, incidents: incidents)
    }

    // MARK: - Helpers

    private static func matches(_ regex: NSRegularExpression, _ text: String) -> Bool {
        let range = NSRange(text.startIndex..., in: text)
        return regex.firstMatch(in: text, options: [], range: range) != nil
    }

    /// The incident's current (latest) state word.
    ///   • `strongPrefixed` (Statuspage) lists updates newest-first, each
    ///     `<strong>State</strong> - …`, so the first `<strong>` is the live state.
    ///   • `statusLabeled` (incident.io, xAI) labels the body with "Status: State"
    ///     (`<b>Status: Resolved</b>` / `<h3>Status: RESOLVED</h3>`).
    private static func currentState(of contentHTML: String, dialect: FeedDialect) -> String? {
        switch dialect {
        case .statusLabeled:
            return firstCapture(in: contentHTML, pattern: "Status:\\s*([A-Za-z][A-Za-z ]*?)\\s*(?:<|$)")
        case .strongPrefixed:
            return firstCapture(in: contentHTML, pattern: "<strong>\\s*([^<]+?)\\s*</strong>")
        }
    }

    /// Whether a state word means the incident is closed. Anything else (Investigating,
    /// Identified, Monitoring, Update, …) — or an unparseable/absent state — is treated as
    /// active, so we'd rather over-surface than silently hide an ongoing incident.
    private static func isResolvedState(_ state: String?) -> Bool {
        guard let s = state?.lowercased() else { return false }
        return s == "resolved" || s == "completed" || s == "postmortem"
    }

    /// First capture group of `pattern` in `text`, trimmed, or nil.
    private static func firstCapture(in text: String, pattern: String) -> String? {
        guard let re = try? NSRegularExpression(
            pattern: pattern, options: [.caseInsensitive, .dotMatchesLineSeparators]) else { return nil }
        let range = NSRange(text.startIndex..., in: text)
        guard let m = re.firstMatch(in: text, options: [], range: range), m.numberOfRanges > 1,
              let r = Range(m.range(at: 1), in: text) else { return nil }
        return String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines)
    }

    /// Strip HTML tags and decode the handful of entities these feeds use, collapsing
    /// whitespace — enough to match component names against the body prose.
    private static func htmlToText(_ html: String) -> String {
        var s = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
        let entities = [
            "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"",
            "&#39;": "'", "&apos;": "'", "&nbsp;": " ",
        ]
        for (entity, char) in entities { s = s.replacingOccurrences(of: entity, with: char) }
        s = s.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
        return s.trimmingCharacters(in: .whitespacesAndNewlines)
    }

    /// Parse a feed timestamp. Atom uses ISO-8601 — Statuspage stamps whole seconds (`…23Z`),
    /// incident.io adds milliseconds (`…27.053Z`); RSS (xAI) uses RFC-822
    /// (`Wed, 17 Jun 2026 12:13:15 GMT`). Try each in turn.
    private static func parseDate(_ string: String) -> Date? {
        guard !string.isEmpty else { return nil }
        let withFraction = ISO8601DateFormatter()
        withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        if let date = withFraction.date(from: string) { return date }
        let plain = ISO8601DateFormatter()
        plain.formatOptions = [.withInternetDateTime]
        if let date = plain.date(from: string) { return date }
        // RFC-822 for RSS `pubDate`. POSIX locale so the English month/day names parse
        // regardless of the user's locale; `d`/`zzz` tolerate both "7"/"17" days and
        // "GMT"/"+0000" zones. Built per-call (a shared static `DateFormatter` isn't
        // Sendable-safe in this nonisolated context).
        let rfc822 = DateFormatter()
        rfc822.locale = Locale(identifier: "en_US_POSIX")
        rfc822.dateFormat = "EEE, d MMM yyyy HH:mm:ss zzz"
        return rfc822.date(from: string)
    }
}

// MARK: - Feed envelope parsing

/// A raw feed entry, before provider-specific interpretation. Dates are kept as the raw
/// string so all date handling lives in one place (`StatusFeedFetcher.parseDate`).
struct FeedEntry {
    var id = ""
    var title = ""
    var link: URL?
    var updatedRaw = ""
    /// The entry body HTML (Atom `<content>`/`<summary>` or RSS `<description>`).
    var content = ""
}

/// Parses an Atom **or** RSS feed into raw entries using Foundation's event-driven `XMLParser`
/// — no third-party XML dependency (the package is Foundation-only). Recognizes both
/// vocabularies: Atom `<entry>`/`<id>`/`<updated>`/`<content>` and RSS
/// `<item>`/`<guid>`/`<pubDate>`/`<description>`. Bodies arrive as decoded markup whether the
/// feed entity-escapes them (Statuspage) or wraps them in CDATA (incident.io, xAI).
final class FeedParser: NSObject, XMLParserDelegate {
    static func parse(_ data: Data) throws -> [FeedEntry] {
        let parser = XMLParser(data: data)
        let delegate = FeedParser()
        parser.delegate = delegate
        guard parser.parse() else { throw StatusFeedError.malformed }
        return delegate.entries
    }

    private var entries: [FeedEntry] = []
    /// The entry being built; nil outside an `<entry>`/`<item>`, which is how feed/channel-level
    /// `<id>`/`<title>`/`<link>` (and the same tags before the first entry) are ignored.
    private var current: FeedEntry?
    private var buffer = ""
    /// Atom `<summary>` body, used only when an entry has no `<content>`.
    private var summary = ""

    func parser(
        _ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
        qualifiedName qName: String?, attributes: [String: String]
    ) {
        switch elementName {
        case "entry", "item":
            current = FeedEntry()
            summary = ""
        case "link":
            // Atom links carry the URL in an href attribute (take rel="alternate" / a bare
            // href, skip rel="self"). RSS links carry it as text — handled on element end.
            guard current != nil, current?.link == nil,
                  let href = attributes["href"], !href.isEmpty else { break }
            let rel = attributes["rel"]
            if rel == nil || rel == "alternate" { current?.link = URL(string: href) }
        default:
            break
        }
        buffer = ""
    }

    func parser(_ parser: XMLParser, foundCharacters string: String) {
        buffer += string
    }

    func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
        if let string = String(data: CDATABlock, encoding: .utf8) { buffer += string }
    }

    func parser(
        _ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
        qualifiedName qName: String?
    ) {
        defer { buffer = "" }
        guard current != nil else { return }   // ignore everything outside an entry/item
        let text = buffer.trimmingCharacters(in: .whitespacesAndNewlines)
        switch elementName {
        case "id", "guid": current?.id = text             // Atom id / RSS guid
        case "title": current?.title = text
        case "updated", "pubDate": current?.updatedRaw = text   // Atom updated / RSS pubDate
        case "content", "description": current?.content = text  // Atom content / RSS description
        case "summary": summary = text
        case "link":
            // RSS: the URL is the element's text. (Atom set it from href on element start.)
            if current?.link == nil, !text.isEmpty { current?.link = URL(string: text) }
        case "entry", "item":
            if var entry = current {
                if entry.content.isEmpty { entry.content = summary }
                if !entry.id.isEmpty { entries.append(entry) }
            }
            current = nil
        default:
            break
        }
    }
}
