import SwiftUI

// Live tapestry weave. The minute-bucketed rows from the server are
// concatenated into one continuous string and rendered as a single
// wrapping monospace Text — the weave's "wall" is class-based color,
// not whitespace, so per-minute row breaks chop up busy minutes and
// stretch quiet minutes into 1–3-character lines that read as broken.
//
// Polling cadence: 3s while foregrounded + active. Suspended when the
// view disappears (no orphan tasks). The TS-side equivalent lives at
// src/lib/tapestry/poll.ts.

@MainActor
final class TapestryFeedModel: ObservableObject {
    @Published var rows: [TapestryBucketResponse] = []
    @Published var isLoading: Bool = false
    @Published var lastError: String?
    /// Flips true after the first poll iteration completes (success OR
    /// error). Drives the "loading vs the weave is quiet" branch in the
    /// view: a genuinely quiet instance returns `[]` and would otherwise
    /// leave the user staring at an indefinite spinner.
    @Published private(set) var hasCompletedFirstPoll: Bool = false

    private var pollTask: Task<Void, Never>?
    /// `accountId` of the last `start()` that entered the cold-start
    /// path. When `start()` is called again with the same id, and rows
    /// are still in memory, and the cache hasn't aged out, skip the
    /// hydrate / gap-fill / backfill phases and jump straight into the
    /// 3 s polling loop — `@StateObject` keeps this instance alive
    /// across tab-switches, but the model has no other way to know it's
    /// already done the cold-start work for this account.
    private var lastWarmStartAccountId: String?

    /// Mirror of the phone's `MIN_LENGTH_FOR_VIEWPORT` / `MAX_BACKFILL_PAGES`
    /// (see src/lib/tapestry/range.ts). Sized for two viewports' worth of
    /// content: the 49 mm watch face at the body-monospace-semibold weight
    /// fits ~50 chars/line × 5–6 lines per viewport (~300 chars) — under
    /// the previous 200 floor the backfill bailed mid-viewport on quiet
    /// instances, leaving the user with a half-empty wall and no scroll
    /// affordance. 600 ≈ two viewports, with `maxBackfillPages` as the
    /// absolute walk-back cap.
    private static let minWeaveChars = 600
    private static let maxBackfillPages = 48  // 24 h × 2 pages/h
    /// Discard persisted history older than this on cold-launch — past
    /// this point the gap is too wide to catch up via `since` and the
    /// content isn't fresh enough to read as "what's happening". Matches
    /// the server's `MAX_SINCE_MINUTES` and the main app's `MAX_GAP_PAGES`.
    private static let maxCacheAgeMinutes: TimeInterval = 48 * 60

    func start(client: WatchAPIClient, username: String, accountId: String) {
        stop()
        let instance = client.instance

        // Warm-restart path. Tab-switches / lifecycle re-entries are
        // the hot path on this view — the `@StateObject` instance
        // persists across them, but the cold-start branch below walks
        // up to `maxBackfillPages` (48) per visit. On a quiet instance
        // that's ~200 server hits per 6 minutes of normal use just from
        // re-entering the tab. Skip hydrate / gap-fill / backfill when
        // the in-memory state is still valid for this account; the
        // polling loop alone keeps `rows`, the App Group cache, and the
        // complication snapshot fresh going forward.
        //
        // Falls through to cold start when: the account changed, rows
        // were never populated (interrupted prior cold start), or the
        // cache aged past the freshness window since the last entry.
        if let warm = lastWarmStartAccountId,
            warm == accountId,
            !rows.isEmpty,
            isCacheStillFresh()
        {
            DebugRecorder.shared.uiNote(
                "tapestry.warm-restart accountId=…\(accountId.suffix(6)) rows=\(rows.count)"
            )
            pollTask = Task { [weak self] in
                await self?.runPollingLoop(
                    client: client,
                    instance: instance,
                    username: username
                )
            }
            return
        }

        // Cold start. Reset visible state so a credential rotation
        // never shows the previous account's letters for a frame; the
        // polling Task overwrites this on the first successful fetch.
        rows = []
        lastError = nil
        hasCompletedFirstPoll = false
        // Mark warm up-front so a mid-flight cancel + restart hits the
        // warm path on re-entry iff rows were hydrated before the
        // interruption. Empty-rows re-entries fall back to cold via
        // the `!rows.isEmpty` guard above.
        lastWarmStartAccountId = accountId

        pollTask = Task { [weak self] in
            guard let self else { return }

            // ──────────────────────────────────────────────────────
            // 1. Hydrate from disk so the UI lights up pre-network.
            // ──────────────────────────────────────────────────────
            let cached = AppGroupCache.readTapestryHistory(instance: instance)
            let newestCachedBucket = cached.max(by: { $0.bucket < $1.bucket })?.bucket
            let cacheAge: TimeInterval? = newestCachedBucket
                .flatMap(TapestryWindow.parseBucket(_:))
                .map { Date().timeIntervalSince($0) / 60 }
            let cacheIsFresh = cacheAge.map { $0 <= TapestryFeedModel.maxCacheAgeMinutes }
                ?? false
            if cacheIsFresh && !cached.isEmpty {
                await self.merge(cached)
                await MainActor.run {
                    self.hasCompletedFirstPoll = true
                }
                DebugRecorder.shared.uiNote(
                    "tapestry.cache hydrate \(cached.count) buckets newest=\(newestCachedBucket ?? "?")"
                )
            } else if !cached.isEmpty {
                // Stale beyond 48 h — drop it.
                AppGroupCache.clearTapestryHistory(instance: instance)
                DebugRecorder.shared.uiNote(
                    "tapestry.cache discard stale (age=\(Int(cacheAge ?? 0))min)"
                )
            }

            // ──────────────────────────────────────────────────────
            // 2. Gap-fill: prefer the bulk `since` endpoint when we
            //    have a fresh cache anchor. Fall back to the page
            //    walker on 404 (older server) or on the empty-cache
            //    path where we have no `since` value to send.
            // ──────────────────────────────────────────────────────
            var pagesFetched = 0
            var didSinceGapFill = false
            if cacheIsFresh, let anchor = newestCachedBucket {
                do {
                    let fetched = try await client.fetchTapestrySince(anchor)
                    await self.merge(fetched)
                    didSinceGapFill = true
                    DebugRecorder.shared.uiNote(
                        "tapestry.since \(anchor) → \(fetched.count) buckets"
                    )
                } catch TapestrySinceError.notSupported {
                    DebugRecorder.shared.uiNote(
                        "tapestry.since not-supported — falling back to page walk"
                    )
                } catch TapestrySinceError.tooOld {
                    // Should be unreachable given the 48 h cache check
                    // above, but server / device clock skew at the
                    // boundary could land here. Keep the cached rows in
                    // place — they're still the freshest record we have
                    // for this instance, and dropping them blanks the
                    // user's screen on a cold launch. The backward
                    // backfill below walks current pages and merges
                    // them in newest-first; whatever gap exists between
                    // our cached rows and "now" will be filled there.
                    DebugRecorder.shared.uiNote(
                        "tapestry.since too-old — keeping cache, relying on backfill"
                    )
                } catch {
                    DebugRecorder.shared.uiNote(
                        "tapestry.since ERR \(error.localizedDescription)"
                    )
                }
            }

            // ──────────────────────────────────────────────────────
            // 3. Eager backward backfill — same as before, runs when
            //    the accumulated weave is still short of the viewport
            //    floor. Bounded by `maxBackfillPages` on a quiet
            //    instance.
            // ──────────────────────────────────────────────────────
            var page: String? = TapestryWindow.currentPage()
            // If `since` already covered the current page (and beyond),
            // start the backward walk at the previous page so we don't
            // re-fetch what we just got.
            if didSinceGapFill, let p = page {
                page = TapestryWindow.previousPage(p)
            }
            while let p = page,
                pagesFetched < TapestryFeedModel.maxBackfillPages,
                !Task.isCancelled
            {
                let accumulated = await self.weaveLength()
                if accumulated >= TapestryFeedModel.minWeaveChars { break }
                do {
                    let history = try await client.fetchTapestryPage(p)
                    await self.merge(history)
                    pagesFetched += 1
                    DebugRecorder.shared.uiNote(
                        "tapestry.page \(p) → \(history.count) buckets"
                    )
                    page = TapestryWindow.previousPage(p)
                } catch {
                    DebugRecorder.shared.uiNote(
                        "tapestry.page \(p) ERR \(error.localizedDescription)"
                    )
                    break
                }
            }
            DebugRecorder.shared.uiNote(
                "tapestry.backfill done pages=\(pagesFetched)"
            )

            // Persist whatever we have now — gap-fill + backfill may
            // have added rows worth keeping for the next cold launch.
            await self.persistCache(instance: instance)

            await self.runPollingLoop(
                client: client,
                instance: instance,
                username: username
            )
        }
    }

    func stop() {
        pollTask?.cancel()
        pollTask = nil
    }

    /// Force the next `start()` to take the cold-start branch even
    /// when the in-memory state would otherwise qualify for a warm
    /// restart. Wired to the view's `.refreshable` so the user can
    /// rebuild from scratch if the weave looks wrong; equivalent to
    /// the phone's pull-to-refresh contract.
    func forceRefresh() {
        lastWarmStartAccountId = nil
    }

    /// Whether the newest in-memory bucket is still inside the cache-
    /// freshness window. Used by the warm-restart guard so a watch
    /// that's been backgrounded long enough for `rows` to age past the
    /// 48 h ceiling falls back to cold-start (which would otherwise
    /// drop the cache and re-hydrate against the server).
    private func isCacheStillFresh() -> Bool {
        guard let newest = rows.max(by: { $0.bucket < $1.bucket })?.bucket,
            let parsed = TapestryWindow.parseBucket(newest)
        else { return false }
        let ageMinutes = Date().timeIntervalSince(parsed) / 60
        return ageMinutes <= TapestryFeedModel.maxCacheAgeMinutes
    }

    /// The 3 s polling loop shared between cold-start and warm-restart
    /// paths. Maintains `rows`, the App Group history cache, and the
    /// complication snapshot. Exits on `Task.isCancelled` (driven by
    /// `stop()` from the view's `onDisappear`).
    private func runPollingLoop(
        client: WatchAPIClient,
        instance: String,
        username: String
    ) async {
        while !Task.isCancelled {
            let minute = TapestryWindow.currentBucket()
            do {
                let fetched = try await client.fetchTapestryBucket(minute)
                let changed = await self.mergeReportingChange(fetched)
                await self.writeComplicationSnapshot(
                    fetched: fetched,
                    instance: instance,
                    username: username
                )
                // Only republish observable state when something
                // actually changed. `@Published` fires
                // `objectWillChange.send()` on every assignment
                // regardless of value equality, so unconditional
                // writes every 3s invalidated every SwiftUI body
                // observing the model — a perceptible re-render
                // on quiet minutes. The first poll still flips
                // `hasCompletedFirstPoll` (gated below) so the
                // spinner clears.
                await MainActor.run {
                    let needsFlip = changed
                        || self.lastError != nil
                        || self.isLoading
                        || !self.hasCompletedFirstPoll
                    if needsFlip {
                        self.lastError = nil
                        self.isLoading = false
                        self.hasCompletedFirstPoll = true
                    }
                }
                // Only re-encode the cache when the poll added or
                // grew a bucket — most ticks land on an unchanged
                // current minute and shouldn't churn disk.
                if changed {
                    await self.persistCache(instance: instance)
                }
            } catch {
                await MainActor.run {
                    self.lastError = error.localizedDescription
                    self.isLoading = false
                    self.hasCompletedFirstPoll = true
                }
            }
            try? await Task.sleep(nanoseconds: 3_000_000_000)
        }
    }

    private func merge(_ fetched: [TapestryBucketResponse]) async {
        _ = await mergeReportingChange(fetched)
    }

    /// Merge, returning `true` if any bucket was added or an existing
    /// bucket's letters string changed. Used by the polling loop to
    /// decide whether to re-encode the persisted cache.
    private func mergeReportingChange(
        _ fetched: [TapestryBucketResponse]
    ) async -> Bool {
        await MainActor.run {
            var byBucket: [String: TapestryBucketResponse] = [:]
            for row in self.rows { byBucket[row.bucket] = row }
            var changed = false
            for row in fetched {
                let prev = byBucket[row.bucket]
                if prev?.letters != row.letters {
                    changed = true
                }
                byBucket[row.bucket] = row
            }
            self.rows = byBucket.values.sorted(by: { $0.bucket > $1.bucket })
            return changed
        }
    }

    /// Total characters across all merged buckets. Used by the cold-start
    /// eager backfill to decide whether to walk another page back.
    private func weaveLength() async -> Int {
        await MainActor.run {
            self.rows.reduce(0) { $0 + $1.letters.count }
        }
    }

    /// Encode `self.rows` to the App Group cache for `instance`. Runs
    /// on the main actor (since `self.rows` is `@Published`), then
    /// hands the encoded blob to `AppGroupCache` which is
    /// `UserDefaults`-backed and thread-safe.
    private func persistCache(instance: String) async {
        let snapshot = await MainActor.run { self.rows }
        AppGroupCache.writeTapestryHistory(instance: instance, rows: snapshot)
    }

    private func writeComplicationSnapshot(
        fetched: [TapestryBucketResponse],
        instance: String,
        username: String
    ) async {
        // Prefer the freshest row from this tick's fetch. When the
        // current bucket was empty (quiet instance, common case), fall
        // back to the newest row already merged into `self.rows` —
        // backfill / persisted history have populated it from prior
        // ticks. Without this fallback the writer never fires on a
        // quiet instance and the complication never has a snapshot
        // to render. The empty-letters branch below covers the
        // pre-history cold-start case so the complication still
        // updates `updatedAt`, letting its staleness label stay
        // accurate.
        var latest = fetched.max(by: { $0.bucket < $1.bucket })
        if latest == nil {
            latest = await MainActor.run {
                self.rows.max(by: { $0.bucket < $1.bucket })
            }
        }
        let snapshot = AppGroupCache.ComplicationSnapshot(
            instance: instance,
            username: username,
            letters: latest?.letters ?? "",
            bucket: latest?.bucket ?? TapestryWindow.currentBucket(),
            updatedAt: Date(),
            // Reaching this writer at all means a successful tapestry
            // fetch landed for `instance`, so the server clearly
            // supports it. The complication's "unavailable" branch is
            // driven by the BackgroundRefresh writer in the
            // not-supported case.
            supportsTapestry: true
        )
        AppGroupCache.writeSnapshot(snapshot)
    }
}

struct TapestryFeedView: View {
    @EnvironmentObject var auth: AuthState
    @StateObject private var model = TapestryFeedModel()

    /// All minute-bucket letters concatenated newest-first into a single
    /// string, with no separator — the weave's texture is the per-class
    /// color contrast, not whitespace.
    private var weave: String {
        model.rows
            .sorted(by: { $0.bucket > $1.bucket })
            .map(\.letters)
            .joined()
    }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 8) {
                contentBranch
            }
        }
        .refreshable {
            // Manual refresh: drop the warm-start anchor so the next
            // .task firing rebuilds from disk + server. We can't call
            // model.start() directly here because the credentials guard
            // belongs in one place (.task). Toggling auth.credentials's
            // identity would also work but is heavier; flipping the
            // anchor lets the existing .task re-run path do the work.
            model.forceRefresh()
            model.stop()
            // Touch nothing else — the .task block re-fires when
            // SwiftUI re-enters the view, which is the common path
            // after a pull-to-refresh on watchOS. On the rare path
            // where .task doesn't re-fire (already-mounted view, same
            // accountId), the user's next tab-switch reaches the cold
            // path on re-entry via the cleared anchor.
            if let client = auth.client, let creds = auth.credentials {
                model.start(
                    client: client,
                    username: creds.username,
                    accountId: creds.accountId
                )
            }
        }
        .task(id: auth.credentials?.accountId) {
            DebugRecorder.shared.uiTaskFired(
                "TapestryFeedView.task",
                accountId: auth.credentials?.accountId
            )
            // The view is only mounted when RootView's Tab(value:) sees
            // `auth.supportsTapestry == true`, so the capability gate is
            // upstream. The remaining guard is the credentials/client
            // pair — both can be nil on first-launch before the keychain
            // hydrates or between a `clear()` and a re-handoff.
            guard let client = auth.client, let creds = auth.credentials else {
                let why = auth.credentials == nil ? "credentials==nil" : "client==nil"
                DebugRecorder.shared.uiNote("TapestryFeedView.task: skip — \(why)")
                model.stop()
                return
            }
            DebugRecorder.shared.uiNote(
                "TapestryFeedView.task: model.start instance=\(client.instance)"
            )
            model.start(
                client: client,
                username: creds.username,
                accountId: creds.accountId
            )
        }
        .onDisappear {
            DebugRecorder.shared.uiNote("TapestryFeedView.onDisappear → model.stop")
            model.stop()
        }
    }

    @ViewBuilder
    private var contentBranch: some View {
        // The "not available on this server" empty state has been
        // retired: RootView only mounts this view when the active
        // account's server advertises the capability, so the
        // unsupported path is unreachable. `auth.tapestryProbed`
        // stays alive as a diagnostic in DebugRecorder.
        if weave.isEmpty {
            quietOrLoading
        } else {
            Text(classColoredWeave(weave))
                .font(.system(.body, design: .monospaced).weight(.semibold))
                .multilineTextAlignment(.leading)
                .frame(maxWidth: .infinity, alignment: .topLeading)
                .fixedSize(horizontal: false, vertical: true)
                .padding(.horizontal, 4)
        }
    }

    private var quietOrLoading: some View {
        Group {
            if model.hasCompletedFirstPoll {
                VStack(spacing: 4) {
                    Text("The weave is quiet")
                        .font(.footnote)
                        .foregroundStyle(.secondary)
                }
                .padding()
            } else {
                ProgressView()
            }
        }
    }

    /// Build an AttributedString that paints each maximal same-class
    /// run in its class tint. Mirror of `classRuns` in
    /// src/components/tapestry/TapestryRow.tsx — the iOS palette uses
    /// Tamagui tokens (`$accent`, `$accentMuted`, …); here the class
    /// tints lean on SwiftUI's semantic colors so the weave stays
    /// legible across watchOS's tinted / vibrant rendering modes.
    /// §7 will swap the `.authored` tint to the active account's
    /// accent variant.
    private func classColoredWeave(_ s: String) -> AttributedString {
        guard !s.isEmpty else { return AttributedString() }
        var result = AttributedString()
        var runStart = s.startIndex
        var currentClass = TapestryLetters.letterClass(of: s[runStart])
        var i = s.index(after: runStart)
        while i < s.endIndex {
            let cls = TapestryLetters.letterClass(of: s[i])
            if cls != currentClass {
                result.append(makeRun(s[runStart..<i], cls: currentClass))
                runStart = i
                currentClass = cls
            }
            i = s.index(after: i)
        }
        result.append(makeRun(s[runStart..<s.endIndex], cls: currentClass))
        return result
    }

    private func makeRun(_ slice: Substring, cls: TapestryLetterClass) -> AttributedString {
        var seg = AttributedString(String(slice))
        seg.foregroundColor = tint(for: cls)
        return seg
    }

    private func tint(for cls: TapestryLetterClass) -> Color {
        // The active account's accent variant (mirrored from the phone's
        // AccountSummary.accentVariant via §7's HandoffPayload field)
        // seeds the `authored` palette so the watch's weave matches the
        // phone's swatch picker; the other classes lean on SwiftUI's
        // semantic colours so the wall stays legible across watchOS's
        // tinted / vibrant rendering modes.
        let accent = AccentVariants.tint(for: auth.credentials?.accentVariant ?? 1)
        switch cls {
        case .authored: return accent
        case .social: return .primary
        case .communication: return .secondary
        case .consumption: return Color.primary.opacity(0.55)
        case .corrective: return .red
        case .unknown: return .primary
        }
    }
}
