import SwiftUI

// Home timeline. The watch's home is "the latest few moments of my
// feed", not a scrollable archive — phone-side feed retention is for
// the phone. Cap is 20 (the server's default first-page request), no
// pagination. Phone-side equivalents: src/hooks/useHomeFeed.ts and
// src/hooks/mutations/useLikeMutation.ts.
//
// Container: List + .listStyle(.plain) over ScrollView + LazyVStack.
// WWDC23 10031 "Update your app for watchOS 10" demonstrates the
// canonical pattern; List integrates Digital Crown scroll and the
// watchOS-11 Double Tap scroll affordance automatically (WWDC24
// 10205). With a 20-item cap the laziness argument for LazyVStack
// doesn't apply.
//
// Image loading: MediaImageStore + WatchAPIClient.fetchImage replaces
// AsyncImage. The store's `failed` state feeds back into `renderable`
// so a post whose thumbnail won't load drops out of the rendered list
// — no ghost rows of (placeholder + username + heart). The carve-out
// is AccountView's avatar, which still uses AsyncImage (one image, no
// renderable filter needed).

@Observable
@MainActor
final class MediaFeedModel {
    // @Observable per the modern Apple-recommended migration path
    // (watchOS 10+, fine-grained dependency tracking). The watch's
    // older models (AuthState, DebugRecorder, TapestryFeedModel) stay
    // on ObservableObject; migrate those if profiling justifies it.

    // Bounded-fill pagination. Mastodon's `/api/v1/timelines/home`
    // exposes no `only_media` filter (it's a public/hashtag-only
    // parameter), so a single-page fetch on a text-heavy timeline
    // collapses to "1 visible post". We page backward with
    // `max_id` until any of these cutoffs trips:
    //
    //   1. `mediaTargetCount` media-bearing statuses collected — done.
    //   2. `maxPagesScanned` pages walked — return what we have.
    //   3. wall-clock > `totalTimeBudget` — return what we have.
    //   4. server has no `next` Link header — end of timeline.
    //
    // The "media-bearing" pre-filter here is `hasRenderableMedia` —
    // a status with at least one image / gifv / video attachment.
    // It's a soft pre-filter: the cell-level `MediaImageStore.failed`
    // pass downstream still trims posts whose thumbnail won't decode,
    // so a 403 on a CDN URL drops the cell at render time. We
    // over-collect at this layer and let the renderer take the
    // final cut.
    // `nonisolated` so the background `LatestMediaRefresh` can read
    // `perPageLimit` off the main actor when picking how many
    // statuses to request for the foreground-shared snapshot path.
    nonisolated static let mediaTargetCount: Int = 20
    nonisolated static let maxPagesScanned: Int = 4
    nonisolated static let perPageLimit: Int = 40
    nonisolated static let totalTimeBudget: TimeInterval = 4.0

    var statuses: [WatchStatus] = []
    var isLoading: Bool = false
    var lastError: String?
    /// Diagnostic telemetry from the most recent `reload` — surfaces
    /// to the empty-state copy when zero media posts were collected
    /// so the user knows whether the watch hit the page cap or
    /// genuinely reached the end of the timeline.
    private(set) var lastScanReport: ScanReport?

    struct ScanReport: Equatable {
        let pagesScanned: Int
        let statusesScanned: Int
        let endReached: Bool
        let timedOut: Bool
    }

    /// Account-switch entry point. Resets the in-memory feed and the
    /// image store before fetching so the new account never sees the
    /// previous account's statuses (or cached image bytes) for a frame.
    /// Called from the view's `.task(id:)` keyed on
    /// `auth.credentials?.accountId`; SwiftUI cancels the prior task.
    func loadInitial(client: WatchAPIClient, imageStore: MediaImageStore) async {
        statuses = []
        imageStore.reset()
        lastError = nil
        lastScanReport = nil
        await reload(client: client)
    }

    func reload(client: WatchAPIClient) async {
        isLoading = true
        let startedAt = Date()
        var collected: [WatchStatus] = []
        var seenIds = Set<String>()
        var nextMaxId: String? = nil
        var pagesScanned = 0
        var statusesScanned = 0
        var endReached = false
        var timedOut = false
        var loopError: String? = nil

        while collected.count < Self.mediaTargetCount,
              pagesScanned < Self.maxPagesScanned,
              !endReached
        {
            if Date().timeIntervalSince(startedAt) >= Self.totalTimeBudget {
                timedOut = true
                break
            }
            do {
                let page = try await client.fetchHomeFeed(
                    maxId: nextMaxId,
                    limit: Self.perPageLimit
                )
                pagesScanned += 1
                statusesScanned += page.statuses.count
                for s in page.statuses where Self.hasRenderableMedia(s) {
                    if seenIds.insert(s.id).inserted {
                        collected.append(s)
                        if collected.count >= Self.mediaTargetCount { break }
                    }
                }
                if let next = page.nextMaxId, !page.statuses.isEmpty {
                    nextMaxId = next
                } else {
                    endReached = true
                }
            } catch {
                loopError = error.localizedDescription
                break
            }
        }
        statuses = Array(collected.prefix(Self.mediaTargetCount))
        lastScanReport = ScanReport(
            pagesScanned: pagesScanned,
            statusesScanned: statusesScanned,
            endReached: endReached,
            timedOut: timedOut
        )
        lastError = loopError
        isLoading = false
        DebugRecorder.shared.uiNote(
            "MediaFeedModel.reload pages=\(pagesScanned) scanned=\(statusesScanned) media=\(statuses.count) end=\(endReached) timeout=\(timedOut)"
        )
    }

    /// True if the status has at least one image / gifv / video
    /// attachment with a resolvable URL. Delegates to
    /// `WatchStatus.thumbnailUrl` so the "what counts as renderable"
    /// rule lives in one place — the same source of truth the cell
    /// uses for its `.task(id:)` image fetch and the Latest Media
    /// complication writer uses to pick the snapshot status.
    static func hasRenderableMedia(_ status: WatchStatus) -> Bool {
        status.thumbnailUrl != nil
    }

    /// Posts the view should actually render. Drops:
    ///   - posts with no thumbnailable media (text-only / audio-only),
    ///   - posts whose thumbnail fetch resolved to `.failed`.
    /// `.loading` and `.idle` stay in the list so the cell can render
    /// a placeholder until its `.task` completes one way or the other.
    func renderable(in store: MediaImageStore) -> [WatchStatus] {
        statuses.filter { status in
            guard status.thumbnailUrl != nil else { return false }
            if case .failed = store.state(for: status.id) { return false }
            return true
        }
    }

    func toggleFavourite(of status: WatchStatus, client: WatchAPIClient) async {
        let original = status
        applyOptimistic(original, want: !(status.favourited ?? false))
        do {
            let updated: WatchStatus
            if original.favourited ?? false {
                updated = try await client.unfavouriteStatus(original.id)
            } else {
                updated = try await client.favouriteStatus(original.id)
            }
            replace(updated)
        } catch {
            replace(original)
            lastError = error.localizedDescription
        }
    }

    private func applyOptimistic(_ status: WatchStatus, want: Bool) {
        guard let idx = statuses.firstIndex(where: { $0.id == status.id }) else { return }
        let s = statuses[idx]
        let newCount = max(0, (s.favouritesCount ?? 0) + (want ? 1 : -1))
        statuses[idx] = WatchStatus(
            id: s.id,
            content: s.content,
            createdAt: s.createdAt,
            account: s.account,
            mediaAttachments: s.mediaAttachments,
            favourited: want,
            favouritesCount: newCount,
            sensitive: s.sensitive,
            spoilerText: s.spoilerText
        )
    }

    private func replace(_ status: WatchStatus) {
        guard let idx = statuses.firstIndex(where: { $0.id == status.id }) else { return }
        statuses[idx] = status
    }
}

struct MediaFeedView: View {
    @EnvironmentObject var auth: AuthState
    @State private var model = MediaFeedModel()
    @State private var imageStore = MediaImageStore()

    var body: some View {
        List {
            contentBranch
        }
        .listStyle(.plain)
        .contentMargins(.vertical, 8, for: .scrollContent)
        .refreshable {
            if let client = auth.client {
                await model.reload(client: client)
                await writeLatestMediaSnapshot(client: client)
            }
        }
        .task(id: auth.credentials?.accountId) {
            DebugRecorder.shared.uiTaskFired(
                "MediaFeedView.task",
                accountId: auth.credentials?.accountId
            )
            if let client = auth.client {
                DebugRecorder.shared.uiNote(
                    "MediaFeedView.task: loadInitial instance=\(client.instance)"
                )
                await model.loadInitial(client: client, imageStore: imageStore)
                await writeLatestMediaSnapshot(client: client)
            } else {
                DebugRecorder.shared.uiNote(
                    "MediaFeedView.task: skip — auth.client==nil"
                )
            }
        }
    }

    /// Opportunistic Latest Media complication update. The feed view
    /// just fetched fresh statuses, so the complication writer reuses
    /// that data instead of spending another HTTP round-trip — gives
    /// sub-minute freshness on the complication whenever the user
    /// opens the watch app, without affecting the background-refresh
    /// budget. Runs after the model's load completes, off the
    /// rendering hot path.
    ///
    /// Skips when the most recent reload errored out — we don't want
    /// a transient network failure to overwrite a previously-good
    /// snapshot with an empty placeholder. The background refresh's
    /// own writer reaches the same conclusion via its catch arm
    /// (leaves the prior snapshot in place).
    private func writeLatestMediaSnapshot(client: WatchAPIClient) async {
        guard let creds = auth.credentials else { return }
        guard model.lastError == nil else { return }
        await LatestMediaRefresh.writeFromForegroundFeed(
            instance: creds.instance,
            username: creds.username,
            statuses: model.statuses,
            client: client
        )
    }

    @ViewBuilder
    private var contentBranch: some View {
        let renderable = model.renderable(in: imageStore)
        if model.statuses.isEmpty && model.isLoading {
            HStack {
                Spacer()
                ProgressView()
                Spacer()
            }
            .listRowBackground(Color.clear)
        } else if renderable.isEmpty {
            Text(emptyStateCopy)
                .font(.footnote)
                .foregroundStyle(.secondary)
                .frame(maxWidth: .infinity, alignment: .leading)
                .listRowBackground(Color.clear)
        } else {
            ForEach(renderable) { status in
                MediaCell(
                    status: status,
                    store: imageStore,
                    client: auth.client,
                    accent: accent,
                    onLongPressLike: {
                        guard let client = auth.client else { return }
                        Task { await model.toggleFavourite(of: status, client: client) }
                    }
                )
                .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
                .listRowBackground(Color.clear)
            }
        }
    }

    private var accent: Color {
        AccentVariants.tint(for: auth.credentials?.accentVariant ?? 1)
    }

    /// Empty-state copy that reflects what the bounded fill actually did.
    /// "No posts yet" is reserved for the brand-new account / zero-status
    /// case; otherwise we distinguish "the watch scanned N statuses and
    /// none had loadable media" from "we hit the end of the timeline".
    private var emptyStateCopy: String {
        if let report = model.lastScanReport, model.statuses.isEmpty {
            if report.endReached {
                return "No media posts in this account's home timeline"
            }
            if report.timedOut {
                return "No media posts in the last \(report.statusesScanned) statuses (timed out)"
            }
            if report.statusesScanned > 0 {
                return "No media posts in the last \(report.statusesScanned) statuses"
            }
            return "No posts yet"
        }
        if model.statuses.isEmpty {
            return "No posts yet"
        }
        return "\(model.statuses.count) posts, none with loadable images"
    }
}

private struct MediaCell: View {
    let status: WatchStatus
    var store: MediaImageStore
    let client: WatchAPIClient?
    let accent: Color
    let onLongPressLike: () -> Void

    private var isFavourited: Bool { status.favourited ?? false }
    private var likeCount: Int { status.favouritesCount ?? 0 }

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            imageBranch
            metadataRow
        }
        .contentShape(.rect)
        .onLongPressGesture(minimumDuration: 0.4, perform: onLongPressLike)
        .sensoryFeedback(.success, trigger: isFavourited)
        .task(id: status.id) {
            // SwiftUI cancels this task when the cell scrolls off the
            // lazy window or the status.id changes — no model-owned
            // [String: Task] registry needed. The MediaImageStore maps
            // URLError.cancelled back to `.idle` so a re-entry retries.
            guard let client, let url = status.thumbnailUrl else { return }
            await store.load(statusId: status.id, url: url, via: client)
        }
        .tint(accent)
    }

    @ViewBuilder
    private var imageBranch: some View {
        switch store.state(for: status.id) {
        case .loaded(let image):
            Image(uiImage: image)
                .resizable()
                .aspectRatio(contentMode: .fill)
                .accessibilityHidden(true)
                .frame(maxWidth: .infinity)
                .frame(height: 120)
                .clipped()
                .clipShape(.rect(cornerRadius: 6))
        case .loading, .idle:
            ZStack {
                Rectangle().fill(.secondary.opacity(0.2))
                ProgressView()
            }
            .frame(maxWidth: .infinity)
            .frame(height: 120)
            .clipShape(.rect(cornerRadius: 6))
        case .failed:
            // Unreachable in the rendered list — `renderable` filters
            // failed cells out one render later. Keep the branch so the
            // switch is exhaustive and the brief intermediate render
            // (between the .failed state landing and the next eval of
            // `renderable`) doesn't paint a ghost rectangle.
            EmptyView()
        }
    }

    private var metadataRow: some View {
        HStack(alignment: .firstTextBaseline, spacing: 6) {
            Text("@\(status.account.username)")
                .font(.caption2)
                .foregroundStyle(.secondary)
                .lineLimit(1)
                .truncationMode(.middle)
                .layoutPriority(1)
            Spacer(minLength: 4)
            Image(systemName: isFavourited ? "heart.fill" : "heart")
                .symbolRenderingMode(.hierarchical)
                .foregroundStyle(isFavourited ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
                .contentTransition(.symbolEffect(.replace))
                .symbolEffect(.bounce, value: likeCount)
            Text("\(likeCount)")
                .font(.caption2)
                .foregroundStyle(.secondary)
                .monospacedDigit()
                .contentTransition(.numericText(value: Double(likeCount)))
                .animation(.snappy, value: likeCount)
        }
    }
}
