import ForgejoKit
import Foundation
import Testing
import UserNotifications

@testable import Forji

/// Integration tests for notification polling components against a real Docker Forgejo instance.
/// These tests require Docker containers to be running (`just docker-up && just seed`).
/// Serialized because some tests mark notifications as read on the server.
@Suite(.serialized)
struct NotificationPollingIntegrationTests {

    private static let username = "testadmin"
    private static let password = "admin1234"

    /// Reads the Forgejo test server URL from the temp file written by the justfile.
    private static func resolveServerURL() -> String? {
        let deviceName = (ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] ?? "")
            .replacingOccurrences(of: " ", with: "_")
        let specificPath = "/tmp/forgejo_test_url_\(deviceName).txt"
        let genericPath = "/tmp/forgejo_test_url.txt"

        return ((try? String(contentsOfFile: specificPath, encoding: .utf8))
            ?? (try? String(contentsOfFile: genericPath, encoding: .utf8)))
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
    }

    /// Creates a ForgejoClient authenticated with testadmin's token.
    private static func makeClient() async throws -> (ForgejoClient, String) {
        guard let serverURL = resolveServerURL() else {
            throw IntegrationTestError.serverNotRunning
        }
        let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)
        let result = try await ForgejoClient.login(
            serverURL: normalizedURL,
            username: username,
            password: password,
        )
        return (result.client, normalizedURL)
    }

    // MARK: - Fetch Notifications from Real Server

    @Test(.enabled(if: resolveServerURL() != nil))
    func fetchNotificationsReturnsResults() async throws {
        let (client, _) = try await Self.makeClient()
        let service = NotificationService(client: client)

        let notifications = try await service.fetchNotifications(
            statusTypes: ["unread"], page: 1, limit: 50
        )

        // The seeder creates issues and PRs from testbot on test-repo,
        // which generates notifications for testadmin (repo owner)
        #expect(!notifications.isEmpty, "Expected at least one unread notification from seeded data")
        #expect(notifications.first?.repository.fullName == "testadmin/test-repo")
    }

    // MARK: - SeenNotificationStore with Real Notification IDs

    @Test(.enabled(if: resolveServerURL() != nil))
    func seenStoreTracksRealNotificationIDs() async throws {
        let (client, normalizedURL) = try await Self.makeClient()
        let service = NotificationService(client: client)
        let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")

        let notifications = try await service.fetchNotifications(
            statusTypes: ["unread"], page: 1, limit: 50
        )
        let ids = Set(notifications.map(\.id))
        #expect(!ids.isEmpty)

        // Seed, should record all current IDs
        store.seed(ids: ids, for: normalizedURL, username: Self.username)
        #expect(store.isSeeded(for: normalizedURL, username: Self.username))
        #expect(store.seenIDs(for: normalizedURL, username: Self.username) == ids)

        // Simulate a poll returning the same IDs, no new notifications
        let newIDs = ids.subtracting(store.seenIDs(for: normalizedURL, username: Self.username))
        #expect(newIDs.isEmpty, "After seeding with current IDs, diff should be empty")
    }

    @Test(.enabled(if: resolveServerURL() != nil))
    func seenStoreDetectsNewNotification() async throws {
        let (client, normalizedURL) = try await Self.makeClient()
        let service = NotificationService(client: client)
        let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")

        let notifications = try await service.fetchNotifications(
            statusTypes: ["unread"], page: 1, limit: 50
        )
        let allIDs = Set(notifications.map(\.id))
        #expect(allIDs.count >= 2, "Need at least 2 notifications for this test")

        // Seed with only a subset, simulates having seen all but one
        let partial = Set(allIDs.dropFirst())
        store.seed(ids: partial, for: normalizedURL, username: Self.username)

        // Now diff, the first ID should show as "new"
        let seenIDs = store.seenIDs(for: normalizedURL, username: Self.username)
        let newIDs = allIDs.subtracting(seenIDs)
        #expect(newIDs.count == 1, "Expected exactly 1 new notification after partial seed")
        #expect(allIDs.first.map { newIDs.contains($0) } == true)
    }

    // MARK: - Prune with Real Data

    @Test(.enabled(if: resolveServerURL() != nil))
    func pruneRemovesIDsNoLongerOnServer() async throws {
        let (client, normalizedURL) = try await Self.makeClient()
        let service = NotificationService(client: client)
        let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")

        let notifications = try await service.fetchNotifications(
            statusTypes: ["unread"], page: 1, limit: 50
        )
        let serverIDs = Set(notifications.map(\.id))

        // Seed with server IDs plus a fake stale ID
        var seeded = serverIDs
        seeded.insert(999_999)
        store.seed(ids: seeded, for: normalizedURL, username: Self.username)
        #expect(store.seenIDs(for: normalizedURL, username: Self.username).contains(999_999))

        // Prune, only keep IDs that the server still reports as unread
        store.prune(keeping: serverIDs, for: normalizedURL, username: Self.username)
        #expect(!store.seenIDs(for: normalizedURL, username: Self.username).contains(999_999))
        #expect(store.seenIDs(for: normalizedURL, username: Self.username) == serverIDs)
    }

    // MARK: - Local Notification Posting

    @Test(.enabled(if: resolveServerURL() != nil))
    func pollPostsLocalNotificationsForNewItems() async throws {
        let (client, normalizedURL) = try await Self.makeClient()
        let service = NotificationService(client: client)

        let notifications = try await service.fetchNotifications(
            statusTypes: ["unread"], page: 1, limit: 50
        )
        #expect(!notifications.isEmpty)

        // Store the token in Keychain so the polling service can find it
        let token = try await KeychainManager.shared.getToken(
            for: normalizedURL, username: Self.username
        )

        // Use a fresh seen store that hasn't been seeded → first poll should seed
        let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
        #expect(!store.isSeeded(for: normalizedURL, username: Self.username))

        // Seed the store, first poll seeds without posting notifications
        let firstPollIDs = Set(notifications.map(\.id))
        store.seed(ids: firstPollIDs, for: normalizedURL, username: Self.username)

        // Verify post-seed state
        #expect(store.isSeeded(for: normalizedURL, username: Self.username))
        #expect(store.seenIDs(for: normalizedURL, username: Self.username) == firstPollIDs)

        // Clean up keychain entry we might have added
        // (the login in makeClient already saved it)
        _ = token
    }

    // MARK: - Unread Count for Badge

    @Test(.enabled(if: resolveServerURL() != nil))
    func unreadCountMatchesNotificationList() async throws {
        let (client, _) = try await Self.makeClient()
        let service = NotificationService(client: client)

        let count = try await service.fetchUnreadCount()
        let notifications = try await service.fetchNotifications(
            statusTypes: ["unread"], page: 1, limit: 50
        )

        // The unread count API should match the number of unread notifications
        // (assuming ≤50 total unreads, which is true for seeded data)
        #expect(count == notifications.count, "Badge count should match notification list count")
    }

    // MARK: - Keychain Accessibility Migration

    @Test(.enabled(if: resolveServerURL() != nil))
    func keychainMigrationPreservesToken() async throws {
        let server = "https://migration-test-\(UUID().uuidString).example.com"
        let user = "migrationuser"
        let token = "test-token-value"

        // Save with current (post-migration) accessibility
        try await KeychainManager.shared.saveToken(token, for: server, username: user)

        // Run migration, should read + delete + re-save without losing data
        await KeychainManager.shared.migrateAccessibility(for: server, username: user)

        // Verify token is still accessible
        let retrieved = try await KeychainManager.shared.getToken(for: server, username: user)
        #expect(retrieved == token)

        // Clean up
        try await KeychainManager.shared.deleteToken(for: server, username: user)
    }

    @Test(.enabled(if: resolveServerURL() != nil))
    func keychainMigrationPreservesPassword() async throws {
        let server = "https://migration-test-\(UUID().uuidString).example.com"
        let user = "migrationuser"
        let password = "test-password"

        try await KeychainManager.shared.savePassword(password, for: server, username: user)
        await KeychainManager.shared.migrateAccessibility(for: server, username: user)

        let retrieved = try await KeychainManager.shared.getPassword(for: server, username: user)
        #expect(retrieved == password)

        // Clean up
        try await KeychainManager.shared.deletePassword(for: server, username: user)
    }

    // MARK: - End-to-End: Full Poll Cycle

    @Test(.enabled(if: resolveServerURL() != nil))
    func fullPollCycleWithSeenStoreDiffAndPrune() async throws {
        let (client, normalizedURL) = try await Self.makeClient()
        let service = NotificationService(client: client)
        let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")

        // --- First poll: seed ---
        let firstPoll = try await service.fetchNotifications(
            statusTypes: ["unread"], page: 1, limit: 50
        )
        let firstIDs = Set(firstPoll.map(\.id))
        #expect(!firstIDs.isEmpty)

        // Not seeded yet → seed
        #expect(!store.isSeeded(for: normalizedURL, username: Self.username))
        store.seed(ids: firstIDs, for: normalizedURL, username: Self.username)
        #expect(store.isSeeded(for: normalizedURL, username: Self.username))

        // --- Second poll: same data, no new notifications ---
        let secondPoll = try await service.fetchNotifications(
            statusTypes: ["unread"], page: 1, limit: 50
        )
        let secondIDs = Set(secondPoll.map(\.id))
        let newAfterSecond = secondIDs.subtracting(
            store.seenIDs(for: normalizedURL, username: Self.username)
        )
        #expect(newAfterSecond.isEmpty, "No new notifications expected on second poll with same data")

        // Mark seen and prune
        store.markSeen(secondIDs, for: normalizedURL, username: Self.username)
        store.prune(keeping: secondIDs, for: normalizedURL, username: Self.username)

        // Store should contain exactly the server-side IDs
        #expect(store.seenIDs(for: normalizedURL, username: Self.username) == secondIDs)
    }

    // MARK: - Badge drops after marking as read

    @Test(.enabled(if: resolveServerURL() != nil))
    func badgeCountDropsAfterMarkingRead() async throws {
        let (client, _) = try await Self.makeClient()
        let service = NotificationService(client: client)

        let notifications = try await service.fetchNotifications(
            statusTypes: ["unread"], page: 1, limit: 50
        )
        try #require(!notifications.isEmpty, "Need at least one unread notification")

        let before = try await service.fetchUnreadCount()
        try await service.markAsRead(id: notifications[0].id)

        let after = try await service.fetchUnreadCount()
        #expect(after == before - 1, "Unread count should decrease by 1 after marking one as read")
    }

    // MARK: - Seen store prunes after mark-read on server

    @Test(.enabled(if: resolveServerURL() != nil))
    func seenStorePrunesAfterMarkRead() async throws {
        let (client, normalizedURL) = try await Self.makeClient()
        let service = NotificationService(client: client)
        let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")

        // Initial poll, seed seen store
        let initial = try await service.fetchNotifications(
            statusTypes: ["unread"], page: 1, limit: 50
        )
        try #require(initial.count >= 2, "Need at least 2 unread notifications")
        let initialIDs = Set(initial.map(\.id))
        store.seed(ids: initialIDs, for: normalizedURL, username: Self.username)

        // Mark one as read on the server
        let markedID = initial[0].id
        try await service.markAsRead(id: markedID)

        // Next poll, fewer unreads
        let afterMark = try await service.fetchNotifications(
            statusTypes: ["unread"], page: 1, limit: 50
        )
        let afterIDs = Set(afterMark.map(\.id))
        #expect(!afterIDs.contains(markedID), "Marked notification should no longer be unread")

        // Prune, the marked ID should be removed from seen store
        store.prune(keeping: afterIDs, for: normalizedURL, username: Self.username)
        #expect(
            !store.seenIDs(for: normalizedURL, username: Self.username).contains(markedID),
            "Pruned seen store should not contain the marked-as-read ID"
        )
        #expect(store.seenIDs(for: normalizedURL, username: Self.username) == afterIDs)
    }

    // MARK: - Seen store dedup and foreground poll behavior

    @Test
    func seenStoreDeduplicatesAcrossPolls() async throws {
        let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
        let server = "https://test.example.com"
        let user = "testuser"

        // Seed with partial IDs, ID 1 is "seen", ID 2 is "new"
        store.seed(ids: [1], for: server, username: user)

        // Simulate a poll returning IDs [1, 2]
        let currentIDs: Set<Int> = [1, 2]
        let seenIDs = store.seenIDs(for: server, username: user)
        let newIDs = currentIDs.subtracting(seenIDs)

        // New IDs should be detected
        #expect(newIDs == [2], "Should detect ID 2 as new")

        // Mark new IDs as seen and prune
        store.markSeen(newIDs, for: server, username: user)
        store.prune(keeping: currentIDs, for: server, username: user)

        // After poll, all IDs should be seen
        #expect(store.seenIDs(for: server, username: user) == [1, 2])

        // A subsequent poll with the same IDs should find no new notifications
        let nextNewIDs = currentIDs.subtracting(store.seenIDs(for: server, username: user))
        #expect(nextNewIDs.isEmpty, "Second poll should find nothing new")
    }

    // MARK: - Background Download Request Construction

    @Test(.enabled(if: resolveServerURL() != nil))
    func backgroundDownloadBuildsCorrectRequest() async throws {
        guard let serverURL = Self.resolveServerURL() else {
            throw IntegrationTestError.serverNotRunning
        }
        let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)

        let token = try await {
            _ = try await ForgejoClient.login(
                serverURL: normalizedURL,
                username: Self.username,
                password: Self.password
            )
            return try await KeychainManager.shared.getToken(
                for: normalizedURL, username: Self.username
            )
        }()

        // Build the same URL that BackgroundDownloadManager would
        var components = URLComponents(string: "\(normalizedURL)/api/v1/notifications")!
        components.queryItems = [
            URLQueryItem(name: "status-types", value: "unread"),
            URLQueryItem(name: "page", value: "1"),
            URLQueryItem(name: "limit", value: "50"),
        ]
        let url = try #require(components.url)

        var request = URLRequest(url: url)
        request.setValue("token \(token)", forHTTPHeaderField: "Authorization")

        // Verify URL structure
        #expect(url.path.hasSuffix("/api/v1/notifications"))
        #expect(url.query?.contains("status-types=unread") == true)
        #expect(url.query?.contains("page=1") == true)
        #expect(url.query?.contains("limit=50") == true)
        #expect(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("token ") == true)
    }

    // MARK: - Background Download Response Parsing

    @Test(.enabled(if: resolveServerURL() != nil))
    func backgroundDownloadParsesNotificationResponse() async throws {
        guard let serverURL = Self.resolveServerURL() else {
            throw IntegrationTestError.serverNotRunning
        }
        let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)

        _ = try await ForgejoClient.login(
            serverURL: normalizedURL,
            username: Self.username,
            password: Self.password
        )
        let token = try await KeychainManager.shared.getToken(
            for: normalizedURL, username: Self.username
        )

        // Make the same request BackgroundDownloadManager would
        var components = URLComponents(string: "\(normalizedURL)/api/v1/notifications")!
        components.queryItems = [
            URLQueryItem(name: "status-types", value: "unread"),
            URLQueryItem(name: "page", value: "1"),
            URLQueryItem(name: "limit", value: "50"),
        ]

        var request = URLRequest(url: components.url!)
        request.setValue("token \(token)", forHTTPHeaderField: "Authorization")

        let (data, response) = try await URLSession.shared.data(for: request)
        let httpResponse = try #require(response as? HTTPURLResponse)
        #expect(httpResponse.statusCode == 200)

        // Parse with the same decoder BackgroundDownloadManager uses
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
        let notifications = try decoder.decode([NotificationThread].self, from: data)

        #expect(!notifications.isEmpty, "Expected unread notifications from seeded data")
        #expect(notifications.first?.repository.fullName == "testadmin/test-repo")
    }

    // MARK: - Background Download Detects New Notifications

    @Test(.enabled(if: resolveServerURL() != nil))
    func backgroundDownloadDetectsNewNotifications() async throws {
        guard let serverURL = Self.resolveServerURL() else {
            throw IntegrationTestError.serverNotRunning
        }
        let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)

        _ = try await ForgejoClient.login(
            serverURL: normalizedURL,
            username: Self.username,
            password: Self.password
        )
        let token = try await KeychainManager.shared.getToken(
            for: normalizedURL, username: Self.username
        )

        // Fetch notifications the same way BackgroundDownloadManager does
        var components = URLComponents(string: "\(normalizedURL)/api/v1/notifications")!
        components.queryItems = [
            URLQueryItem(name: "status-types", value: "unread"),
            URLQueryItem(name: "page", value: "1"),
            URLQueryItem(name: "limit", value: "50"),
        ]

        var request = URLRequest(url: components.url!)
        request.setValue("token \(token)", forHTTPHeaderField: "Authorization")

        let (data, _) = try await URLSession.shared.data(for: request)
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
        let notifications = try decoder.decode([NotificationThread].self, from: data)
        let allIDs = Set(notifications.map(\.id))
        #expect(allIDs.count >= 2, "Need at least 2 notifications")

        // Seed with a subset, simulate having seen all but one
        let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
        let partial = Set(allIDs.dropFirst())
        store.seed(ids: partial, for: normalizedURL, username: Self.username)

        // Diff, should detect the missing ID as new
        let seenIDs = store.seenIDs(for: normalizedURL, username: Self.username)
        let newIDs = allIDs.subtracting(seenIDs)
        #expect(newIDs.count == 1, "Expected exactly 1 new notification")
    }

    // MARK: - Background Download Updates Badge

    @Test(.enabled(if: resolveServerURL() != nil))
    func backgroundDownloadUpdatesBadge() async throws {
        guard let serverURL = Self.resolveServerURL() else {
            throw IntegrationTestError.serverNotRunning
        }
        let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)

        _ = try await ForgejoClient.login(
            serverURL: normalizedURL,
            username: Self.username,
            password: Self.password
        )
        let token = try await KeychainManager.shared.getToken(
            for: normalizedURL, username: Self.username
        )

        // Fetch unread count via raw API (same as BackgroundDownloadManager)
        var components = URLComponents(string: "\(normalizedURL)/api/v1/notifications")!
        components.queryItems = [
            URLQueryItem(name: "status-types", value: "unread"),
            URLQueryItem(name: "page", value: "1"),
            URLQueryItem(name: "limit", value: "50"),
        ]

        var request = URLRequest(url: components.url!)
        request.setValue("token \(token)", forHTTPHeaderField: "Authorization")

        let (data, _) = try await URLSession.shared.data(for: request)
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
        let notifications = try decoder.decode([NotificationThread].self, from: data)

        // The badge count should equal the number of unread notifications
        let expectedBadge = notifications.count
        #expect(expectedBadge > 0, "Expected at least one unread notification for badge test")

        // Also verify via the count API
        let (client, _) = try await Self.makeClient()
        let service = NotificationService(client: client)
        let apiCount = try await service.fetchUnreadCount()
        #expect(apiCount == expectedBadge, "Badge count should match notification list")
    }

    // MARK: - Task Description Round Trip (Unit Test)

    @Test
    func taskDescriptionRoundTrips() throws {
        let metadata = InstanceMetadata(
            serverURL: "https://codeberg.org",
            username: "testuser",
            name: "Codeberg"
        )

        let encoded = try JSONEncoder().encode(metadata)
        let decoded = try JSONDecoder().decode(InstanceMetadata.self, from: encoded)

        #expect(decoded.serverURL == metadata.serverURL)
        #expect(decoded.username == metadata.username)
        #expect(decoded.name == metadata.name)
    }
}

private enum IntegrationTestError: Error {
    case serverNotRunning
}
