import SwiftUI

@MainActor
@Observable
final class PaginationState<Item> {
    var items: [Item] = []
    private(set) var isLoading = false
    private(set) var hasMore = true
    private(set) var hasLoaded = false
    var errorMessage: String?
    var showError = false
    var notFound = false

    private var currentPage = 1
    private var loadTask: Task<Void, Never>?
    let pageSize: Int
    var cacheName: String?

    /// Optional key extractor enabling cross-page de-duplication. Views that
    /// combine several paginated sources set this (merged multi-instance lists,
    /// or the per-flag involvement queries), because the same item can resurface
    /// on a later page and the combined page size no longer matches `pageSize`.
    @ObservationIgnored var dedupeKey: ((Item) -> AnyHashable)?
    @ObservationIgnored private var seenKeys = Set<AnyHashable>()

    init(pageSize: Int = 20) {
        self.pageSize = pageSize
    }

    init(items: [Item], hasMore: Bool = false, pageSize: Int = 20, notFound: Bool = false) {
        self.items = items
        self.hasMore = hasMore
        self.pageSize = pageSize
        self.notFound = notFound
    }

    @discardableResult
    func reload(
        clearItems: Bool = false,
        using fetch: @escaping (_ page: Int, _ limit: Int) async throws -> [Item],
    ) -> Task<Void, Never> {
        loadTask?.cancel()
        if clearItems { items = [] }
        currentPage = 1
        hasMore = true
        hasLoaded = false
        isLoading = true
        showError = false
        notFound = false
        let pageSize = pageSize
        let task = Task {
            do {
                let fetched = try await fetch(1, pageSize)
                guard !Task.isCancelled else { return }
                if dedupeKey != nil { seenKeys.removeAll() }
                let newItems = deduped(fetched)
                items = newItems
                hasMore = computeHasMore(fetched: fetched, newItems: newItems)
                currentPage = 2
                hasLoaded = true
            } catch is CancellationError {
                // Ignore cancellation
            } catch let urlError as URLError where urlError.code == .cancelled {
                // Ignore URLSession cancellation from cancelled tasks
            } catch {
                guard !Task.isCancelled else { return }
                errorMessage = error.localizedDescription
                showError = true
            }
            if !Task.isCancelled {
                isLoading = false
            }
        }
        loadTask = task
        return task
    }

    func loadMore(using fetch: @escaping (_ page: Int, _ limit: Int) async throws -> [Item]) async {
        guard hasMore, !isLoading else { return }
        isLoading = true
        let currentPage = currentPage
        let pageSize = pageSize
        let task = Task {
            do {
                let fetched = try await fetch(currentPage, pageSize)
                guard !Task.isCancelled else { return }
                let newItems = deduped(fetched)
                items.append(contentsOf: newItems)
                hasMore = computeHasMore(fetched: fetched, newItems: newItems)
                self.currentPage = currentPage + 1
            } catch is CancellationError {
                // Ignore cancellation
            } catch let urlError as URLError where urlError.code == .cancelled {
                // Ignore URLSession cancellation from cancelled tasks
            } catch {
                guard !Task.isCancelled else { return }
                errorMessage = error.localizedDescription
                showError = true
            }
            if !Task.isCancelled {
                isLoading = false
            }
        }
        loadTask = task
        await task.value
    }

    func invalidate() {
        hasLoaded = false
    }

    /// Filters out items already seen on a previous page when `dedupeKey` is set,
    /// recording the new keys. Without a `dedupeKey` the page passes through
    /// unchanged so single-source pagination keeps its existing behavior.
    private func deduped(_ fetched: [Item]) -> [Item] {
        guard let dedupeKey else { return fetched }
        return fetched.filter { seenKeys.insert(dedupeKey($0)).inserted }
    }

    /// For merged sources the de-duplicated, multi-instance page size no longer
    /// matches `pageSize`, so "more pages exist" means "this page contributed new
    /// items." Single sources keep the page-size heuristic.
    private func computeHasMore(fetched: [Item], newItems: [Item]) -> Bool {
        if dedupeKey != nil {
            return !newItems.isEmpty
        }
        return fetched.count >= pageSize
    }
}

// MARK: - Disk cache

extension PaginationState where Item: Codable {
    func loadFromCache() {
        guard let cacheName, !hasLoaded else { return }
        guard let cached: [Item] = DiskCache.load(as: cacheName) else { return }
        items = cached
    }

    func saveToCache() {
        guard let cacheName, hasLoaded, !items.isEmpty else { return }
        DiskCache.save(items, as: cacheName)
    }

    @discardableResult
    func reloadAndCache(
        clearItems: Bool = false,
        using fetch: @escaping (_ page: Int, _ limit: Int) async throws -> [Item],
    ) -> Task<Void, Never> {
        let task = reload(clearItems: clearItems, using: fetch)
        return Task {
            await task.value
            saveToCache()
        }
    }

    func loadMoreAndCache(
        using fetch: @escaping (_ page: Int, _ limit: Int) async throws -> [Item],
    ) async {
        await loadMore(using: fetch)
        saveToCache()
    }
}
