import Foundation

// Shared persistence between the watch app and the complication via
// the App Group container `group.ht.halflig.app`. The watch app
// writes the most recent tapestry snapshot here on every successful
// poll; the complication's TimelineProvider reads it without a
// network round-trip when WidgetKit asks for a snapshot (within the
// 40–70 reloads/day budget — see TapestryComplication).

enum AppGroupCache {
    private static let suiteName = Branding.appGroupId
    // Bumped v1 → v2 when `supportsTapestry` was added to the snapshot
    // shape: the complication target compiles independently from the
    // watch app, so an in-flight Decodable extension would surface as a
    // decode failure on the consumer side. Bumping the key forces the
    // complication's reader to return nil for legacy data — falling
    // back to the empty state for one refresh cycle until the new
    // writer populates v2 — rather than a silent miss. The two halves
    // (writer here, reader in HalflightComplication.swift) MUST keep
    // the suffix in sync.
    private static let snapshotKey = "tapestry.complication.snapshot.v2"
    private static let snapshotUpdatedKey = "tapestry.complication.updatedAt.v2"
    // Per-instance backfill cache: a flat `[{bucket, letters}]` JSON
    // blob keyed by host. Lets a cold-launched watch app render the
    // weave immediately before any network round-trip, and lets the
    // `since` gap-fill request the minimal range to catch up.
    private static let historyKeyPrefix = "tapestry.history.v1."
    private static let historyUpdatedKeyPrefix = "tapestry.history.v1.updatedAt."
    /// Defensive cap on encoded history blob size. 48 h × 60 min = 2880
    /// is the worst-case populated-buckets count under the server's
    /// `since` ceiling; we trim to this before writing.
    private static let historyMaxBuckets = 2880
    // Plan §10A Phase C exit criterion #4 — the complication's
    // `getTimeline` increments this counter each time it runs so we
    // can verify the 24h reload count stays under WidgetKit's
    // documented 40–70 soft cap. Per-UTC-day rollover keeps the
    // counter from growing unbounded. Audit shape is independent of
    // the snapshot shape, so the v1 suffix stays.
    private static let auditKey = "tapestry.complication.audit.v1"

    // Second complication — Latest Media. Independent snapshot record
    // alongside the tapestry one; keys are namespaced so the two
    // widgets can evolve their shapes without colliding. The same App
    // Group container hosts both. Bump the v* suffix when the shape
    // changes — the reader (in the complication target) fails closed
    // on decode error and falls back to the empty/placeholder state.
    // v2 reshape: single (statusId, mediaUrl, altText, postedAt,
    // imageBytes) → an `items: [MediaItem]` array carrying up to 3
    // most-recent media posts, so the rectangular complication can
    // render a 3-up grid and the circular complication can crop the
    // first item to a disc.
    private static let latestMediaSnapshotKey = "latest_media.complication.snapshot.v2"
    private static let latestMediaSnapshotUpdatedKey = "latest_media.complication.updatedAt.v2"
    private static let latestMediaAuditKey = "latest_media.complication.audit.v1"
    /// Defensive cap on encoded image bytes inline in UserDefaults,
    /// applied per-MediaItem so v2's 3-item snapshot still totals well
    /// under the App Group's blob comfort zone. 25 KB is enough for a
    /// 120×120 JPEG at our quality target; the writer downscales
    /// before encoding so this is a defensive backstop, not the live
    /// budget. v1 used a 60 KB single-image cap.
    static let latestMediaImageMaxBytes: Int = 25 * 1024
    /// Number of most-recent media items the writer is allowed to
    /// pack into a single snapshot. Bound by the rectangular
    /// complication's 3-up layout — the circular variant only
    /// renders the first slot. Keep in sync with the writer's
    /// downscale loop in `LatestMediaRefresh`.
    static let latestMediaMaxItems: Int = 3

    struct ComplicationSnapshot: Codable, Equatable {
        let instance: String
        let username: String
        // Most recent ribbon row, e.g. "LRsvLBP". Empty string means "no
        // letters this minute" — the complication still renders, but
        // dashes the gaps.
        let letters: String
        let bucket: String
        let updatedAt: Date
        // False when the active account's server does not expose the
        // tapestry endpoint. The complication renders an "unavailable"
        // branch in that case rather than the empty state, so a user
        // who pinned the complication while signed into a Halflight
        // account and then switched to a non-Halflight one sees a
        // self-explanatory message instead of "No data yet" forever.
        let supportsTapestry: Bool
    }

    struct ReloadAudit: Codable, Equatable {
        let utcDay: String
        let count: Int
    }

    // Latest Media complication. Mirrors `ComplicationSnapshot`'s
    // contract: the watch app writes, the complication target reads.
    // The complication has no keychain / network surface — every
    // field the view renders has to be in this record at write time,
    // including the pre-downscaled JPEG bytes.
    //
    // `items.isEmpty` denotes an empty state (signed out, no media
    // in the feed yet, fetch failed). The complication's view
    // renders an icon-only placeholder in that case rather than
    // ghosting the previous account's content.
    //
    // `postedAt` on each item is the status's `created_at`.
    // `updatedAt` at the snapshot level is when the writer last ran
    // — drives the staleness gate (>2h → photos replaced by an SF
    // symbol). The two timestamps answer different questions: "how
    // old is each post" vs. "how stale is our knowledge of the feed".
    struct LatestMediaSnapshot: Codable, Equatable {
        let instance: String
        let username: String
        let items: [MediaItem]
        let updatedAt: Date
        /// True when the active account has at least one verified-signed-in
        /// credential row. False forces the complication into a "sign in
        /// on iPhone" branch instead of rendering whichever placeholder
        /// shape happens to fit.
        let signedIn: Bool

        struct MediaItem: Codable, Equatable {
            let statusId: String
            let mediaUrl: String
            let altText: String?
            let postedAt: Date
            let imageBytes: Data?
        }
    }

    private static var defaults: UserDefaults? {
        UserDefaults(suiteName: suiteName)
    }

    static func writeSnapshot(_ snapshot: ComplicationSnapshot) {
        guard let defaults else { return }
        if let data = try? JSONEncoder().encode(snapshot) {
            defaults.set(data, forKey: snapshotKey)
            defaults.set(snapshot.updatedAt, forKey: snapshotUpdatedKey)
        }
    }

    static func readSnapshot() -> ComplicationSnapshot? {
        guard let defaults, let data = defaults.data(forKey: snapshotKey) else { return nil }
        return try? JSONDecoder().decode(ComplicationSnapshot.self, from: data)
    }

    static func snapshotUpdatedAt() -> Date? {
        defaults?.object(forKey: snapshotUpdatedKey) as? Date
    }

    /// Drop the cached snapshot. Called on sign-out so the watch face
    /// stops showing the previous account's letters — without this, the
    /// complication's TimelineProvider would keep reading the stale
    /// record indefinitely (it has no keychain access of its own).
    static func clearSnapshot() {
        guard let defaults else { return }
        defaults.removeObject(forKey: snapshotKey)
        defaults.removeObject(forKey: snapshotUpdatedKey)
    }

    /// Today's complication reload count, scoped to the current UTC
    /// day. Returns `nil` when no audit record exists or the stored
    /// record is for a previous day (in which case today's count is
    /// effectively zero — the complication's getTimeline writer
    /// rolls over on first hit).
    static func todaysReloadAudit() -> ReloadAudit? {
        readAudit(key: auditKey)
    }

    /// Today's latest-media complication reload count. Same UTC-day
    /// rollover semantics as `todaysReloadAudit()` — the AccountView
    /// renders both side-by-side under the debug section.
    static func todaysLatestMediaReloadAudit() -> ReloadAudit? {
        readAudit(key: latestMediaAuditKey)
    }

    private static func readAudit(key: String) -> ReloadAudit? {
        guard let defaults, let data = defaults.data(forKey: key) else { return nil }
        guard let decoded = try? JSONDecoder().decode(ReloadAudit.self, from: data) else {
            return nil
        }
        return decoded.utcDay == utcDayString() ? decoded : nil
    }

    // MARK: - Latest Media complication

    static func writeLatestMediaSnapshot(_ snapshot: LatestMediaSnapshot) {
        guard let defaults else { return }
        // Defensive backstop: cap items count and trim any per-item
        // blob that somehow exceeded the per-image byte cap. The
        // writer in `LatestMediaRefresh` already downscales to well
        // under both, so this rarely fires. Trimming JPEG bytes mid-
        // stream would corrupt the image, so we drop the bytes
        // entirely rather than ship a broken blob the consumer will
        // fail to decode.
        let cappedItems = Array(snapshot.items.prefix(latestMediaMaxItems))
        let cleanedItems = cappedItems.map { item -> LatestMediaSnapshot.MediaItem in
            guard let bytes = item.imageBytes,
                bytes.count > latestMediaImageMaxBytes
            else { return item }
            return LatestMediaSnapshot.MediaItem(
                statusId: item.statusId,
                mediaUrl: item.mediaUrl,
                altText: item.altText,
                postedAt: item.postedAt,
                imageBytes: nil
            )
        }
        let cleaned = LatestMediaSnapshot(
            instance: snapshot.instance,
            username: snapshot.username,
            items: cleanedItems,
            updatedAt: snapshot.updatedAt,
            signedIn: snapshot.signedIn
        )
        if let data = try? JSONEncoder().encode(cleaned) {
            defaults.set(data, forKey: latestMediaSnapshotKey)
            defaults.set(cleaned.updatedAt, forKey: latestMediaSnapshotUpdatedKey)
        }
    }

    static func readLatestMediaSnapshot() -> LatestMediaSnapshot? {
        guard let defaults, let data = defaults.data(forKey: latestMediaSnapshotKey)
        else { return nil }
        return try? JSONDecoder().decode(LatestMediaSnapshot.self, from: data)
    }

    static func latestMediaSnapshotUpdatedAt() -> Date? {
        defaults?.object(forKey: latestMediaSnapshotUpdatedKey) as? Date
    }

    /// Drop the latest-media snapshot. Sign-out parallel to
    /// `clearSnapshot()` — without this the complication would keep
    /// rendering the previous account's thumbnail forever.
    static func clearLatestMediaSnapshot() {
        guard let defaults else { return }
        defaults.removeObject(forKey: latestMediaSnapshotKey)
        defaults.removeObject(forKey: latestMediaSnapshotUpdatedKey)
    }

    // MARK: - Tapestry history (per-instance)

    /// Persisted minute buckets for an instance, newest-first.
    static func readTapestryHistory(instance: String) -> [TapestryBucketResponse] {
        guard let defaults,
            let data = defaults.data(forKey: historyKey(instance))
        else { return [] }
        return (try? JSONDecoder().decode([TapestryBucketResponse].self, from: data)) ?? []
    }

    static func tapestryHistoryUpdatedAt(instance: String) -> Date? {
        defaults?.object(forKey: historyUpdatedKey(instance)) as? Date
    }

    /// Replace the cached history for `instance`. Rows are sorted
    /// bucket-descending and trimmed to `historyMaxBuckets` before
    /// encoding so a long-running instance can't grow the blob
    /// unboundedly.
    static func writeTapestryHistory(
        instance: String,
        rows: [TapestryBucketResponse]
    ) {
        guard let defaults else { return }
        let trimmed: [TapestryBucketResponse] = {
            let sorted = rows.sorted(by: { $0.bucket > $1.bucket })
            return sorted.count <= historyMaxBuckets
                ? sorted
                : Array(sorted.prefix(historyMaxBuckets))
        }()
        guard let data = try? JSONEncoder().encode(trimmed) else { return }
        defaults.set(data, forKey: historyKey(instance))
        defaults.set(Date(), forKey: historyUpdatedKey(instance))
    }

    /// Drop the cached history for one instance. Called from
    /// `AuthState.clear()` on sign-out — without this, signing out and
    /// then signing back in on the same device would briefly show the
    /// previous account's history before the live poll catches up.
    static func clearTapestryHistory(instance: String) {
        guard let defaults else { return }
        defaults.removeObject(forKey: historyKey(instance))
        defaults.removeObject(forKey: historyUpdatedKey(instance))
    }

    private static func historyKey(_ instance: String) -> String {
        historyKeyPrefix + instance
    }

    private static func historyUpdatedKey(_ instance: String) -> String {
        historyUpdatedKeyPrefix + instance
    }

    private static func utcDayString(_ date: Date = Date()) -> String {
        var c = Calendar(identifier: .gregorian)
        c.timeZone = TimeZone(identifier: "UTC") ?? .gmt
        let comps = c.dateComponents([.year, .month, .day], from: date)
        return String(
            format: "%04d-%02d-%02d",
            comps.year ?? 0, comps.month ?? 0, comps.day ?? 0
        )
    }
}
