import CoreGraphics
import Foundation
import ImageIO
import UIKit
import UniformTypeIdentifiers
import WidgetKit

// Latest Media complication refresh runner. Sibling to
// `BackgroundRefresh.runOnce()` for the Tapestry complication —
// shares the same 20-minute background-refresh chain. The two
// complications draw from independent App Group records but
// run inside one `WKApplicationRefreshBackgroundTask` per tick
// so we stay inside Apple's "up to four background refreshes per
// hour with an active complication" budget (see
// `WKApplicationRefreshBackgroundTask` docs).
//
// Data path identical to Tapestry's:
//   1. Read credentials from the watch's keychain — the foreground
//      app may not be running.
//   2. Fetch a page of the home timeline. Reuses the bounded-fill
//      cap (`MediaFeedModel.perPageLimit`) so we get a generous
//      window without burning the radio.
//   3. Pick the most-recent media-bearing status via
//      `WatchStatus.thumbnailUrl != nil`.
//   4. Fetch the thumbnail bytes via the same authenticated
//      URLSession used by the feed view.
//   5. Downscale to ~120×120 JPEG, ≤ 60 KB.
//   6. Encode a `LatestMediaSnapshot` and write to the App Group.
//   7. Nudge WidgetKit to reload the LatestMedia kind.
//
// Failure-mode philosophy mirrors Tapestry: leave the previous
// snapshot in place on transient errors. The 2h staleness gate
// in the complication's view replaces the photo with a "stale"
// hint so a paused watch can't ghost last week's content.

enum LatestMediaRefresh {
    /// The complication's WidgetKit kind. Must match the `kind`
    /// declared inside the complication target's
    /// `HalflightLatestMediaComplication` widget. Drift here would
    /// silently break the reload — WidgetKit matches on kind string.
    static let widgetKind = "LatestMediaComplication"

    /// Bound the downscale's longer edge. `accessoryRectangular`
    /// is tiny (≤ 170×65pt) and we crop the thumb into a ~56×56
    /// view slot, but watches render at 3× scale so ~170 px is the
    /// largest we'd ever need. 240 px gives crispness headroom for
    /// future families (`accessoryCircular`, larger watches) at
    /// negligible byte cost after JPEG.
    static let imageMaxDimension: CGFloat = 240
    static let imageInitialQuality: CGFloat = 0.7
    /// Quality ramp for re-encoding when the first attempt exceeds
    /// the App Group cap. Apple's UserDefaults isn't a blob store;
    /// we keep blobs well under the safety ceiling
    /// `AppGroupCache.latestMediaImageMaxBytes`.
    static let imageQualityFloor: CGFloat = 0.3

    /// One-shot refresh — same shape as `BackgroundRefresh.runOnce()`.
    static func runOnce() async {
        guard let creds = WatchKeychain.read() else {
            // Signed out at the moment the background task fires.
            // Replace any prior snapshot with a "sign in on iPhone"
            // placeholder so the watch face doesn't ghost the
            // previous account's media indefinitely after a
            // sign-out that the user performed on the watch.
            writeSignedOutPlaceholder()
            return
        }
        let client = WatchAPIClient(
            instance: creds.instance,
            accessToken: creds.accessToken,
            accountType: creds.accountType
        )
        do {
            let page = try await client.fetchHomeFeed(
                maxId: nil,
                limit: MediaFeedModel.perPageLimit
            )
            await buildAndWriteSnapshot(
                instance: creds.instance,
                username: creds.username,
                statuses: page.statuses,
                client: client
            )
        } catch {
            // Network errors: keep the previous snapshot in place.
            // The complication's 2h staleness gate will surface the
            // age textually if too much time passes between runs.
        }
    }

    /// Foreground entry point. Called from `MediaFeedView` after a
    /// successful load so the same feed page that powers the watch
    /// app's home tab also powers the complication's snapshot —
    /// free fresh data without spending another HTTP round-trip
    /// or a background-refresh slot.
    static func writeFromForegroundFeed(
        instance: String,
        username: String,
        statuses: [WatchStatus],
        client: WatchAPIClient
    ) async {
        await buildAndWriteSnapshot(
            instance: instance,
            username: username,
            statuses: statuses,
            client: client
        )
    }

    // MARK: - Internal

    private static func buildAndWriteSnapshot(
        instance: String,
        username: String,
        statuses: [WatchStatus],
        client: WatchAPIClient
    ) async {
        let picks = pickLatestMedia(
            in: statuses,
            max: AppGroupCache.latestMediaMaxItems
        )
        guard !picks.isEmpty else {
            // No media in the feed window. Write an empty-but-
            // signed-in record so the complication renders an
            // "awaiting media" placeholder instead of ghosting.
            writeEmptyFeedPlaceholder(instance: instance, username: username)
            return
        }
        // Fetch+downscale every pick concurrently — three small
        // thumbnails on a phone-tethered radio is fast enough that
        // serializing would be the wrong tradeoff. Indexes preserved
        // so the rectangular layout renders newest-on-the-left.
        let items: [AppGroupCache.LatestMediaSnapshot.MediaItem] = await withTaskGroup(
            of: (Int, AppGroupCache.LatestMediaSnapshot.MediaItem).self
        ) { group in
            for (idx, pick) in picks.enumerated() {
                group.addTask {
                    let bytes = await fetchAndDownscale(url: pick.url, client: client)
                    let item = AppGroupCache.LatestMediaSnapshot.MediaItem(
                        statusId: pick.status.id,
                        mediaUrl: pick.url.absoluteString,
                        altText: pick.altText,
                        postedAt: pick.postedAt,
                        imageBytes: bytes
                    )
                    return (idx, item)
                }
            }
            var collected: [(Int, AppGroupCache.LatestMediaSnapshot.MediaItem)] = []
            for await pair in group { collected.append(pair) }
            return collected.sorted(by: { $0.0 < $1.0 }).map { $0.1 }
        }
        let snapshot = AppGroupCache.LatestMediaSnapshot(
            instance: instance,
            username: username,
            items: items,
            updatedAt: Date(),
            signedIn: true
        )
        AppGroupCache.writeLatestMediaSnapshot(snapshot)
        WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
    }

    private struct Pick {
        let status: WatchStatus
        let url: URL
        let altText: String?
        let postedAt: Date
    }

    /// Most-recent `max` media-bearing statuses, in feed order
    /// (server's home timeline is newest-first).
    /// `WatchStatus.thumbnailUrl != nil` is the shared source of
    /// truth — same predicate the cell uses.
    ///
    /// Additionally skips thumbnails the watch can't actually decode:
    /// Pixelfed CDNs commonly serve `_thumb.webp`, and `CGImageSource`
    /// on watchOS does NOT carry WebP among its supported type
    /// identifiers (verified via `CGImageSourceCopyTypeIdentifiers()`
    /// on watchOS 11 sim — yields jpeg/png/gif/tiff/jp2/heic/jpeg-xl/…
    /// with no webp entry). The watch feed already drops WebP-only
    /// posts at render time via `MediaImageStore`'s decode-failure
    /// filter, so this aligns the complication's content with what
    /// the user can also see in the watch app's feed list.
    private static func pickLatestMedia(
        in statuses: [WatchStatus],
        max: Int
    ) -> [Pick] {
        var picks: [Pick] = []
        for s in statuses {
            guard let url = s.thumbnailUrl else { continue }
            if url.pathExtension.lowercased() == "webp" { continue }
            let alt = s.mediaAttachments.first { $0.description != nil }?.description
            let posted = parseTimestamp(s.createdAt) ?? Date()
            picks.append(Pick(status: s, url: url, altText: alt, postedAt: posted))
            if picks.count >= max { break }
        }
        return picks
    }

    private static func parseTimestamp(_ s: String?) -> Date? {
        guard let s else { return nil }
        // Mastodon-compatible servers emit RFC 3339 / ISO 8601.
        let iso = ISO8601DateFormatter()
        iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        if let d = iso.date(from: s) { return d }
        iso.formatOptions = [.withInternetDateTime]
        return iso.date(from: s)
    }

    private static func fetchAndDownscale(
        url: URL,
        client: WatchAPIClient
    ) async -> Data? {
        do {
            let raw = try await client.fetchImage(url: url)
            return downscaleJPEG(raw)
        } catch {
            return nil
        }
    }

    /// Down-sample an arbitrary image blob to a small JPEG bounded
    /// by both `imageMaxDimension` and
    /// `AppGroupCache.latestMediaImageMaxBytes`. Returns nil if the
    /// input doesn't decode or every quality step is still over cap.
    //
    // Decode goes through `UIImage(data:)` rather than ImageIO's
    // `CGImageSourceCreateThumbnailAtIndex` because the latter
    // silently returns nil on WebP payloads on watchOS even though
    // ImageIO supposedly supports the format since iOS 14 / watchOS
    // 7. Pixelfed thumbnails are commonly served as `_thumb.webp`,
    // so the thumbnail-API path missed two of three items in v2.
    // UIImage routes through every codec the OS recognizes (JPEG /
    // PNG / WebP / HEIC / GIF), so we get a working decoder for free
    // and then scale + JPEG-encode through CG directly.
    static func downscaleJPEG(_ data: Data) -> Data? {
        guard let cgImage = decodeCGImage(data),
            let scaled = scale(cgImage: cgImage, maxDimension: imageMaxDimension)
        else { return nil }
        var quality = imageInitialQuality
        while quality >= imageQualityFloor {
            if let encoded = encodeJPEG(scaled, quality: quality),
                encoded.count <= AppGroupCache.latestMediaImageMaxBytes
            {
                return encoded
            }
            quality -= 0.1
        }
        // Last resort: a smaller render at the floor quality.
        if let smaller = scale(cgImage: cgImage, maxDimension: imageMaxDimension * 0.6) {
            return encodeJPEG(smaller, quality: imageQualityFloor)
        }
        return nil
    }

    /// Decode an image blob into a CGImage. Tries `UIImage(data:)`
    /// first (handles every codec UIKit recognises, including HEIC),
    /// then falls back to `CGImageSourceCreateImageAtIndex` (which
    /// has different format coverage on some platforms — notably,
    /// ImageIO can decode WebP on watchOS where `UIImage(data:)`
    /// returns nil for the same payload).
    private static func decodeCGImage(_ data: Data) -> CGImage? {
        if let img = UIImage(data: data), let cg = img.cgImage {
            return cg
        }
        guard let source = CGImageSourceCreateWithData(data as CFData, nil),
            CGImageSourceGetCount(source) > 0
        else { return nil }
        return CGImageSourceCreateImageAtIndex(source, 0, nil)
    }

    /// Scale a CGImage to fit within `maxDimension` on its longer
    /// edge, preserving aspect ratio. Returns the input unchanged
    /// when already within bounds.
    private static func scale(cgImage: CGImage, maxDimension: CGFloat) -> CGImage? {
        let w = CGFloat(cgImage.width)
        let h = CGFloat(cgImage.height)
        let longest = max(w, h)
        guard longest > 0 else { return nil }
        if longest <= maxDimension { return cgImage }
        let ratio = maxDimension / longest
        let target = CGSize(width: floor(w * ratio), height: floor(h * ratio))
        guard target.width >= 1, target.height >= 1 else { return nil }
        let bitmapInfo =
            CGImageAlphaInfo.premultipliedFirst.rawValue
            | CGBitmapInfo.byteOrder32Little.rawValue
        guard
            let context = CGContext(
                data: nil,
                width: Int(target.width),
                height: Int(target.height),
                bitsPerComponent: 8,
                bytesPerRow: 0,
                space: CGColorSpaceCreateDeviceRGB(),
                bitmapInfo: bitmapInfo
            )
        else { return nil }
        context.interpolationQuality = .high
        context.draw(cgImage, in: CGRect(origin: .zero, size: target))
        return context.makeImage()
    }

    private static func encodeJPEG(_ image: CGImage, quality: CGFloat) -> Data? {
        let data = NSMutableData()
        guard
            let dest = CGImageDestinationCreateWithData(
                data,
                UTType.jpeg.identifier as CFString,
                1,
                nil
            )
        else { return nil }
        let options: [CFString: Any] = [
            kCGImageDestinationLossyCompressionQuality: quality
        ]
        CGImageDestinationAddImage(dest, image, options as CFDictionary)
        guard CGImageDestinationFinalize(dest) else { return nil }
        return data as Data
    }

    private static func writeSignedOutPlaceholder() {
        let snapshot = AppGroupCache.LatestMediaSnapshot(
            instance: "",
            username: "",
            items: [],
            updatedAt: Date(),
            signedIn: false
        )
        AppGroupCache.writeLatestMediaSnapshot(snapshot)
        WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
    }

    private static func writeEmptyFeedPlaceholder(instance: String, username: String) {
        let snapshot = AppGroupCache.LatestMediaSnapshot(
            instance: instance,
            username: username,
            items: [],
            updatedAt: Date(),
            signedIn: true
        )
        AppGroupCache.writeLatestMediaSnapshot(snapshot)
        WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
    }
}
