import Foundation
import UIKit
import Testing
@testable import Oppi

/// Tests for CodeFontFamily enum — raw values, display names, PostScript name generation, and font creation.
@Suite("CodeFontFamily")
@MainActor
struct CodeFontFamilyTests {

    // MARK: - Raw values

    @Test func systemRawValue() {
        #expect(FontPreferences.CodeFontFamily.system.rawValue == "system")
    }

    @Test func firaCodeRawValue() {
        #expect(FontPreferences.CodeFontFamily.firaCode.rawValue == "FiraCode")
    }

    @Test func jetBrainsMonoRawValue() {
        #expect(FontPreferences.CodeFontFamily.jetBrainsMono.rawValue == "JetBrainsMono")
    }

    @Test func cascadiaCodeRawValue() {
        #expect(FontPreferences.CodeFontFamily.cascadiaCode.rawValue == "CascadiaCode")
    }

    @Test func sourceCodeProRawValue() {
        #expect(FontPreferences.CodeFontFamily.sourceCodePro.rawValue == "SourceCodePro")
    }

    @Test func monaspaceNeonRawValue() {
        #expect(FontPreferences.CodeFontFamily.monaspaceNeon.rawValue == "MonaspaceNeon")
    }

    // MARK: - CaseIterable

    @Test func allCasesCountIs6() {
        #expect(FontPreferences.CodeFontFamily.allCases.count == 6)
    }

    @Test func allCasesContainsEveryFamily() {
        let all = FontPreferences.CodeFontFamily.allCases
        #expect(all.contains(.system))
        #expect(all.contains(.firaCode))
        #expect(all.contains(.jetBrainsMono))
        #expect(all.contains(.cascadiaCode))
        #expect(all.contains(.sourceCodePro))
        #expect(all.contains(.monaspaceNeon))
    }

    // MARK: - Display names

    @Test func displayNames() {
        #expect(FontPreferences.CodeFontFamily.system.displayName == "SF Mono")
        #expect(FontPreferences.CodeFontFamily.firaCode.displayName == "Fira Code")
        #expect(FontPreferences.CodeFontFamily.jetBrainsMono.displayName == "JetBrains Mono")
        #expect(FontPreferences.CodeFontFamily.cascadiaCode.displayName == "Cascadia Code")
        #expect(FontPreferences.CodeFontFamily.sourceCodePro.displayName == "Source Code Pro")
        #expect(FontPreferences.CodeFontFamily.monaspaceNeon.displayName == "Monaspace Neon")
    }

    // MARK: - fontNamePrefix

    @Test func systemPrefixIsNil() {
        #expect(FontPreferences.CodeFontFamily.system.fontNamePrefix == nil)
    }

    @Test func bundledFontPrefixesAreNonNil() {
        let bundled: [FontPreferences.CodeFontFamily] = [
            .firaCode, .jetBrainsMono, .cascadiaCode, .sourceCodePro, .monaspaceNeon,
        ]
        for family in bundled {
            #expect(family.fontNamePrefix != nil, "\(family.rawValue) should have a font name prefix")
        }
    }

    // MARK: - Identifiable

    @Test func idMatchesRawValue() {
        for family in FontPreferences.CodeFontFamily.allCases {
            #expect(family.id == family.rawValue)
        }
    }

    // MARK: - PostScript names

    @Test func systemPostScriptNameIsNilForAllWeights() {
        let sys = FontPreferences.CodeFontFamily.system
        #expect(sys.postScriptName(weight: .regular) == nil)
        #expect(sys.postScriptName(weight: .semibold) == nil)
        #expect(sys.postScriptName(weight: .bold) == nil)
    }

    @Test func firaCodePostScriptNames() {
        let f = FontPreferences.CodeFontFamily.firaCode
        #expect(f.postScriptName(weight: .regular) == "FiraCode-Regular")
        #expect(f.postScriptName(weight: .semibold) == "FiraCode-SemiBold")
        #expect(f.postScriptName(weight: .bold) == "FiraCode-Bold")
    }

    @Test func jetBrainsMonoPostScriptNames() {
        let f = FontPreferences.CodeFontFamily.jetBrainsMono
        #expect(f.postScriptName(weight: .regular) == "JetBrainsMono-Regular")
        #expect(f.postScriptName(weight: .semibold) == "JetBrainsMono-SemiBold")
        #expect(f.postScriptName(weight: .bold) == "JetBrainsMono-Bold")
    }

    @Test func cascadiaCodePostScriptNames() {
        let f = FontPreferences.CodeFontFamily.cascadiaCode
        #expect(f.postScriptName(weight: .regular) == "CascadiaCode-Regular")
        #expect(f.postScriptName(weight: .semibold) == "CascadiaCode-SemiBold")
        #expect(f.postScriptName(weight: .bold) == "CascadiaCode-Bold")
    }

    @Test func sourceCodeProPostScriptNames() {
        let f = FontPreferences.CodeFontFamily.sourceCodePro
        #expect(f.postScriptName(weight: .regular) == "SourceCodePro-Regular")
        // Source Code Pro uses lowercase 'b' in "Semibold"
        #expect(f.postScriptName(weight: .semibold) == "SourceCodePro-Semibold")
        #expect(f.postScriptName(weight: .bold) == "SourceCodePro-Bold")
    }

    @Test func monaspaceNeonPostScriptNames() {
        let f = FontPreferences.CodeFontFamily.monaspaceNeon
        #expect(f.postScriptName(weight: .regular) == "MonaspaceNeon-Regular")
        #expect(f.postScriptName(weight: .semibold) == "MonaspaceNeon-SemiBold")
        #expect(f.postScriptName(weight: .bold) == "MonaspaceNeon-Bold")
    }

    @Test func unmappedWeightDefaultsToRegular() {
        // Weights other than .regular/.semibold/.bold fall through to "Regular" suffix.
        let f = FontPreferences.CodeFontFamily.firaCode
        #expect(f.postScriptName(weight: .light) == "FiraCode-Regular")
        #expect(f.postScriptName(weight: .medium) == "FiraCode-Regular")
        #expect(f.postScriptName(weight: .ultraLight) == "FiraCode-Regular")
        #expect(f.postScriptName(weight: .heavy) == "FiraCode-Regular")
    }

    // MARK: - font(size:weight:) always produces a valid UIFont

    @Test func systemFontFallsBackToSystemMono() {
        let font = FontPreferences.CodeFontFamily.system.font(size: 12, weight: .regular)
        #expect(font.pointSize == 12)
    }

    @Test func allFamiliesProduceValidFonts() {
        let weights: [UIFont.Weight] = [.regular, .semibold, .bold]
        for family in FontPreferences.CodeFontFamily.allCases {
            for weight in weights {
                let font = family.font(size: 11, weight: weight)
                #expect(font.pointSize == 11, "\(family.rawValue) at \(weight) should produce an 11pt font")
            }
        }
    }

    @Test func fontWithUnmappedWeightFallsBackGracefully() {
        // .light weight has no bundled file — should fall back to system mono.
        let font = FontPreferences.CodeFontFamily.firaCode.font(size: 14, weight: .light)
        #expect(font.pointSize == 14)
    }
}

// MARK: - FontPreferences

@Suite("FontPreferences")
@MainActor
struct FontPreferencesTests {

    // MARK: - Code font

    @Test func defaultCodeFontIsSystem() {
        // Read without prior write — should return .system (or whatever is persisted).
        // We can't guarantee a clean UserDefaults in test, so just verify it returns a valid family.
        let family = FontPreferences.codeFont
        #expect(FontPreferences.CodeFontFamily.allCases.contains(family))
    }

    @Test func setCodeFontPersistsAndRebuilds() {
        let original = FontPreferences.codeFont
        defer { FontPreferences.setCodeFont(original) }

        FontPreferences.setCodeFont(.firaCode)
        #expect(FontPreferences.codeFont == .firaCode)

        FontPreferences.setCodeFont(.jetBrainsMono)
        #expect(FontPreferences.codeFont == .jetBrainsMono)
    }

    @Test func setCodeFontRoundTripsAllFamilies() {
        let original = FontPreferences.codeFont
        defer { FontPreferences.setCodeFont(original) }

        for family in FontPreferences.CodeFontFamily.allCases {
            FontPreferences.setCodeFont(family)
            #expect(FontPreferences.codeFont == family, "Round-trip failed for \(family.rawValue)")
        }
    }

    // MARK: - Mono messages

    @Test func setUseMonoForMessagesPersists() {
        let original = FontPreferences.useMonoForMessages
        defer { FontPreferences.setUseMonoForMessages(original) }

        FontPreferences.setUseMonoForMessages(true)
        #expect(FontPreferences.useMonoForMessages == true)

        FontPreferences.setUseMonoForMessages(false)
        #expect(FontPreferences.useMonoForMessages == false)
    }

    @Test func setCodeTextScalePersistsAndClamps() {
        let defaults = captureCodeTextDefaults()
        defer { restoreCodeTextDefaults(defaults) }

        FontPreferences.setCodeTextScale(1.25)
        #expect(FontPreferences.codeTextScale == 1.25)

        FontPreferences.setCodeTextScale(99)
        #expect(FontPreferences.codeTextScale == FontPreferences.maximumCodeTextScale)
    }

    @Test func setMessageTextScalePersistsAndClamps() {
        let defaults = captureCodeTextDefaults()
        defer { restoreCodeTextDefaults(defaults) }

        FontPreferences.setMessageTextScale(1.2)
        #expect(FontPreferences.messageTextScale == 1.2)

        FontPreferences.setMessageTextScale(99)
        #expect(FontPreferences.messageTextScale == FontPreferences.maximumMessageTextScale)
    }

    @Test func defaultCodeTextScaleUsesReadableGlobalBaseline() {
        let defaults = captureCodeTextDefaults()
        defer { restoreCodeTextDefaults(defaults) }

        UserDefaults.standard.removeObject(forKey: "codeTextRelativeScale")
        UserDefaults.standard.removeObject(forKey: "codeTextScale")
        UserDefaults.standard.removeObject(forKey: "codeFontSize")
        AppFont.rebuild()

        #expect(FontPreferences.codeTextScale == FontPreferences.standardCodeTextScale)
        #expect(FontPreferences.codePointSize(baseSize: 10, idiom: .phone) == 11)
        #expect(FontPreferences.codePointSize(baseSize: 11, idiom: .phone) == 12)
        #expect(FontPreferences.codePointSize(baseSize: 12, idiom: .phone) == 13)
        #expect(FontPreferences.codePointSize(baseSize: 11, idiom: .pad) == 12)
        #expect(AppFont.monoSmall.pointSize == 11)
        #expect(AppFont.mono.pointSize == 12)
        #expect(AppFont.monoMedium.pointSize == 13)
    }

    @Test func codePointSizeUsesDeviceLocalScaleWithoutPlatformDelta() {
        let defaults = captureCodeTextDefaults()
        defer { restoreCodeTextDefaults(defaults) }

        FontPreferences.setCodeTextScale(1.25)
        #expect(FontPreferences.codePointSize(baseSize: 10, idiom: .phone) == 14)
        #expect(FontPreferences.codePointSize(baseSize: 10, idiom: .pad) == 14)
        #expect(FontPreferences.codePointSize(baseSize: 11, idiom: .phone) == 15)
        #expect(FontPreferences.codePointSize(baseSize: 11, idiom: .pad) == 15)
    }

    @Test func storedEffectiveScaleMapsCurrentReadableSizeToOneHundredPercent() {
        let defaults = captureCodeTextDefaults()
        defer { restoreCodeTextDefaults(defaults) }

        UserDefaults.standard.removeObject(forKey: "codeTextRelativeScale")
        UserDefaults.standard.set(1.10, forKey: "codeTextScale")
        UserDefaults.standard.removeObject(forKey: "codeFontSize")

        #expect(FontPreferences.codeTextScale == 1.0)
        #expect(FontPreferences.codePointSize(baseSize: 11, idiom: .phone) == 12)
    }

    @Test func storedCodeFontSizePresetsMapToScale() {
        let defaults = captureCodeTextDefaults()
        defer { restoreCodeTextDefaults(defaults) }

        UserDefaults.standard.removeObject(forKey: "codeTextRelativeScale")
        UserDefaults.standard.removeObject(forKey: "codeTextScale")
        UserDefaults.standard.set("compact", forKey: "codeFontSize")
        #expect(FontPreferences.codeTextScale == 1.0)

        UserDefaults.standard.set("large", forKey: "codeFontSize")
        #expect(FontPreferences.codeTextScale == FontPreferences.maximumCodeTextScale)
    }

    @Test func scaledCodeFontKeepsPreferenceAndDynamicType() {
        let originalFamily = FontPreferences.codeFont
        let defaults = captureCodeTextDefaults()
        defer {
            FontPreferences.setCodeFont(originalFamily)
            restoreCodeTextDefaults(defaults)
        }

        let defaultTrait = UITraitCollection(preferredContentSizeCategory: .large)
        let accessibilityTrait = UITraitCollection(
            preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge
        )

        FontPreferences.setCodeFont(.system)
        FontPreferences.setCodeTextScale(1.0)
        let compactDefault = FontPreferences.scaledCodeFont(
            baseSize: 11,
            textStyle: .subheadline,
            idiom: .phone,
            compatibleWith: defaultTrait
        )

        FontPreferences.setCodeTextScale(1.45)
        let largeDefault = FontPreferences.scaledCodeFont(
            baseSize: 11,
            textStyle: .subheadline,
            idiom: .phone,
            compatibleWith: defaultTrait
        )
        let largeAccessibility = FontPreferences.scaledCodeFont(
            baseSize: 11,
            textStyle: .subheadline,
            idiom: .phone,
            compatibleWith: accessibilityTrait
        )

        #expect(largeDefault.pointSize > compactDefault.pointSize)
        #expect(largeAccessibility.pointSize > largeDefault.pointSize)
    }

    // MARK: - Notification

    @Test func setCodeFontPostsNotification() async {
        let original = FontPreferences.codeFont
        defer { FontPreferences.setCodeFont(original) }

        let expectation = NotificationCenter.default.notifications(
            named: FontPreferences.didChangeNotification
        )

        FontPreferences.setCodeFont(.cascadiaCode)

        // Drain one notification — should be available immediately since post is synchronous.
        var received = false
        for await _ in expectation {
            received = true
            break
        }
        #expect(received)
    }

    @Test func setMonoMessagesPostsNotification() async {
        let original = FontPreferences.useMonoForMessages
        defer { FontPreferences.setUseMonoForMessages(original) }

        let expectation = NotificationCenter.default.notifications(
            named: FontPreferences.didChangeNotification
        )

        FontPreferences.setUseMonoForMessages(!original)

        var received = false
        for await _ in expectation {
            received = true
            break
        }
        #expect(received)
    }

    @Test func setCodeTextScalePostsNotification() async {
        let defaults = captureCodeTextDefaults()
        defer { restoreCodeTextDefaults(defaults) }

        let expectation = NotificationCenter.default.notifications(
            named: FontPreferences.didChangeNotification
        )

        FontPreferences.setCodeTextScale(1.2)

        var received = false
        for await _ in expectation {
            received = true
            break
        }
        #expect(received)
    }

    @Test func setMessageTextScalePostsNotification() async {
        let defaults = captureCodeTextDefaults()
        defer { restoreCodeTextDefaults(defaults) }

        let expectation = NotificationCenter.default.notifications(
            named: FontPreferences.didChangeNotification
        )

        FontPreferences.setMessageTextScale(1.15)

        var received = false
        for await _ in expectation {
            received = true
            break
        }
        #expect(received)
    }
}

// MARK: - AppFont rebuild

@Suite("AppFont rebuild")
@MainActor
struct AppFontRebuildTests {

    @Test func rebuildSetsMonoFonts() {
        let originalFamily = FontPreferences.codeFont
        let defaults = captureCodeTextDefaults()
        defer {
            FontPreferences.setCodeFont(originalFamily)
            restoreCodeTextDefaults(defaults)
        }

        // Switch to system and rebuild
        FontPreferences.setCodeFont(.system)
        FontPreferences.setCodeTextScale(1.0)

        #expect(AppFont.mono.pointSize == 12)
        #expect(AppFont.monoBold.pointSize == AppFont.mono.pointSize)
        #expect(AppFont.monoSmall.pointSize == 11)
        #expect(AppFont.monoSmallSemibold.pointSize == AppFont.monoSmall.pointSize)
        #expect(AppFont.monoMedium.pointSize == AppFont.mono.pointSize + 1)
        #expect(AppFont.monoMediumBold.pointSize == AppFont.monoMedium.pointSize)
        #expect(AppFont.monoMediumSemibold.pointSize == AppFont.monoMedium.pointSize)
        #expect(AppFont.monoLarge.pointSize == 15)
        #expect(AppFont.monoLargeSemibold.pointSize == 15)
        #expect(AppFont.monoXL.pointSize == 17)
    }

    @Test func rebuildWithBundledFontChangesMonoConstants() {
        let originalFamily = FontPreferences.codeFont
        let defaults = captureCodeTextDefaults()
        defer {
            FontPreferences.setCodeFont(originalFamily)
            restoreCodeTextDefaults(defaults)
        }

        // Switch to a bundled font
        FontPreferences.setCodeFont(.firaCode)
        FontPreferences.setCodeTextScale(1.0)

        // Point sizes should follow the selected scale regardless of family.
        #expect(AppFont.mono.pointSize == 12)
        #expect(AppFont.monoMedium.pointSize == AppFont.mono.pointSize + 1)
        #expect(AppFont.monoLarge.pointSize == 15)
    }

    @Test func messageBodyIsSystemWhenMonoMessagesDisabled() {
        let originalFamily = FontPreferences.codeFont
        let originalMono = FontPreferences.useMonoForMessages
        let defaults = captureCodeTextDefaults()
        defer {
            FontPreferences.setCodeFont(originalFamily)
            FontPreferences.setUseMonoForMessages(originalMono)
            restoreCodeTextDefaults(defaults)
        }

        FontPreferences.setUseMonoForMessages(false)
        FontPreferences.setMessageTextScale(1.0)
        AppFont.rebuild()

        // Should be the system body font, not a code-font clone at the same size.
        let expected = UIFont.preferredFont(forTextStyle: .body)
        #expect(AppFont.messageBody.pointSize == expected.pointSize)
        #expect(AppFont.messageBody.fontName == expected.fontName)
    }

    @Test func messageBodyIsMonoWhenMonoMessagesEnabled() {
        let originalFamily = FontPreferences.codeFont
        let originalMono = FontPreferences.useMonoForMessages
        let defaults = captureCodeTextDefaults()
        defer {
            FontPreferences.setCodeFont(originalFamily)
            FontPreferences.setUseMonoForMessages(originalMono)
            restoreCodeTextDefaults(defaults)
        }

        FontPreferences.setCodeFont(.system)
        FontPreferences.setUseMonoForMessages(true)
        FontPreferences.setMessageTextScale(1.0)
        AppFont.rebuild()

        // Point size should match system body, while the family switches to monospace.
        let expectedSize = UIFont.preferredFont(forTextStyle: .body).pointSize
        #expect(AppFont.messageBody.pointSize == expectedSize)
        #expect(AppFont.messageBody.fontDescriptor.symbolicTraits.contains(.traitMonoSpace))
    }

    @Test func messageBodyRespectsMessageTextScale() {
        let defaults = captureCodeTextDefaults()
        let originalMono = FontPreferences.useMonoForMessages
        defer {
            FontPreferences.setUseMonoForMessages(originalMono)
            restoreCodeTextDefaults(defaults)
        }

        FontPreferences.setUseMonoForMessages(false)
        FontPreferences.setMessageTextScale(1.2)
        AppFont.rebuild()

        let expected = UIFont.preferredFont(forTextStyle: .body).pointSize * 1.2
        #expect(abs(AppFont.messageBody.pointSize - expected) < 0.01)
    }

    // MARK: - System fonts are static

    @Test func systemFontConstantsAreFixedSize() {
        #expect(AppFont.systemSmall.pointSize == 11)
        #expect(AppFont.systemFeedback.pointSize == 13)
        #expect(AppFont.systemFeedbackMedium.pointSize == 14)
    }
}

@Suite("ToolFont text scale")
@MainActor
struct ToolFontTextScaleTests {
    @Test func defaultUsesReadableToolFontSizes() {
        let defaults = captureCodeTextDefaults()
        defer { restoreCodeTextDefaults(defaults) }

        UserDefaults.standard.removeObject(forKey: "codeTextRelativeScale")
        UserDefaults.standard.removeObject(forKey: "codeTextScale")
        UserDefaults.standard.removeObject(forKey: "codeFontSize")
        AppFont.rebuild()

        #expect(ToolFont.pointSize(baseSize: 10, idiom: .phone) == 11)
        #expect(ToolFont.pointSize(baseSize: 11, idiom: .phone) == 12)
        #expect(ToolFont.pointSize(baseSize: 12, idiom: .phone) == 13)
    }

    @Test func padUsesSameScaleAsPhone() {
        let defaults = captureCodeTextDefaults()
        defer { restoreCodeTextDefaults(defaults) }

        FontPreferences.setCodeTextScale(1.3)
        #expect(ToolFont.pointSize(baseSize: 10, idiom: .phone) == 14)
        #expect(ToolFont.pointSize(baseSize: 10, idiom: .pad) == 14)
        #expect(ToolFont.pointSize(baseSize: 12, idiom: .phone) == 17)
        #expect(ToolFont.pointSize(baseSize: 12, idiom: .pad) == 17)
    }

    @Test func regularToolOutputFontUsesConfiguredScale() {
        let originalFamily = FontPreferences.codeFont
        let defaults = captureCodeTextDefaults()
        defer {
            FontPreferences.setCodeFont(originalFamily)
            restoreCodeTextDefaults(defaults)
        }

        FontPreferences.setCodeFont(.system)
        FontPreferences.setCodeTextScale(1.45)

        let font = ToolFont.regular

        #expect(font.pointSize == AppFont.monoMedium.pointSize)
    }
}

private struct CodeTextDefaultsSnapshot {
    let relativeScale: Any?
    let storedEffectiveScale: Any?
    let storedSizePreset: String?
    let messageScale: Any?
}

@MainActor
private func captureCodeTextDefaults() -> CodeTextDefaultsSnapshot {
    CodeTextDefaultsSnapshot(
        relativeScale: UserDefaults.standard.object(forKey: "codeTextRelativeScale"),
        storedEffectiveScale: UserDefaults.standard.object(forKey: "codeTextScale"),
        storedSizePreset: UserDefaults.standard.string(forKey: "codeFontSize"),
        messageScale: UserDefaults.standard.object(forKey: "messageTextScale")
    )
}

@MainActor
private func restoreCodeTextDefaults(_ snapshot: CodeTextDefaultsSnapshot) {
    if let relativeScale = snapshot.relativeScale {
        UserDefaults.standard.set(relativeScale, forKey: "codeTextRelativeScale")
    } else {
        UserDefaults.standard.removeObject(forKey: "codeTextRelativeScale")
    }

    if let storedEffectiveScale = snapshot.storedEffectiveScale {
        UserDefaults.standard.set(storedEffectiveScale, forKey: "codeTextScale")
    } else {
        UserDefaults.standard.removeObject(forKey: "codeTextScale")
    }

    if let storedSizePreset = snapshot.storedSizePreset {
        UserDefaults.standard.set(storedSizePreset, forKey: "codeFontSize")
    } else {
        UserDefaults.standard.removeObject(forKey: "codeFontSize")
    }

    if let messageScale = snapshot.messageScale {
        UserDefaults.standard.set(messageScale, forKey: "messageTextScale")
    } else {
        UserDefaults.standard.removeObject(forKey: "messageTextScale")
    }

    AppFont.rebuild()
}
