//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//

import Combine
import SwiftUI
import XCTest

@testable import ElementX
@testable import SnapshotTesting

@MainActor
class PreviewTests: XCTestCase {
    private let deviceConfig: ViewImageConfig = .iPhoneX
    private let simulatorDevice: String? = "iPhone14,6" // iPhone SE 3rd Generation
    private let requiredOSVersion = (major: 18, minor: 1)
    private let snapshotDevices = ["iPhone 16", "iPad"]
    private var recordMode: SnapshotTestingConfiguration.Record = .missing

    override func setUp() {
        super.setUp()
        
        if ProcessInfo().environment["RECORD_FAILURES"].map(Bool.init) == true {
            recordMode = .failed
        }

        checkEnvironments()
        UIView.setAnimationsEnabled(false)
    }
    
    /// Check environments to avoid problems with snapshots on different devices or OS.
    private func checkEnvironments() {
        if let simulatorDevice {
            let deviceModel = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"]
            guard deviceModel?.contains(simulatorDevice) ?? false else {
                fatalError("\(deviceModel ?? "Unknown") is the wrong one. Switch to using \(simulatorDevice) for these tests.")
            }
        }

        let osVersion = ProcessInfo().operatingSystemVersion
        guard osVersion.majorVersion == requiredOSVersion.major, osVersion.minorVersion == requiredOSVersion.minor else {
            fatalError("Switch to iOS \(requiredOSVersion) for these tests.")
        }
        guard !snapshotDevices.isEmpty else {
            fatalError("Specify at least one snapshot device to test on.")
        }
    }
    
    // MARK: - Snapshots

    func assertSnapshots(matching preview: _Preview, testName: String = #function) async throws {
        let preferences = SnapshotPreferences()
        
        let preferenceReadingView = preview.content
            .onPreferenceChange(SnapshotPrecisionPreferenceKey.self) { preferences.precision = $0 }
            .onPreferenceChange(SnapshotPerceptualPrecisionPreferenceKey.self) { preferences.perceptualPrecision = $0 }
            .onPreferenceChange(SnapshotFulfillmentPublisherPreferenceKey.self) { preferences.fulfillmentPublisher = $0?.publisher }
        
        // Render an image of the view in order to trigger the preference updates to occur.
        let imageRenderer = ImageRenderer(content: preferenceReadingView)
        _ = imageRenderer.uiImage
        
        if let fulfillmentPublisher = preferences.fulfillmentPublisher {
            let deferred = deferFulfillment(fulfillmentPublisher) { $0 == true }
            try await deferred.fulfill()
        }
        
        for deviceName in snapshotDevices {
            guard var device = PreviewDevice(rawValue: deviceName).snapshotDevice() else {
                fatalError("Unknown device name: \(deviceName)")
            }
            // Ignore specific device safe area (using the workaround value to fix rendering issues).
            device.safeArea = .one
            // Ignore specific device display scale
            let traits = UITraitCollection(displayScale: 2.0)
            if let failure = assertSnapshots(matching: preview.content,
                                             name: preview.displayName,
                                             isScreen: preview.layout == .device,
                                             device: device,
                                             testName: testName + deviceName + "-" + localeCode,
                                             traits: traits,
                                             preferences: preferences) {
                XCTFail(failure)
            }
        }
    }
    
    private var localeCode: String {
        if UserDefaults.standard.bool(forKey: "NSDoubleLocalizedStrings") {
            return "pseudo"
        }
        return languageCode + "-" + regionCode
    }

    private var languageCode: String {
        Locale.current.language.languageCode?.identifier ?? ""
    }

    private var regionCode: String {
        Locale.current.language.region?.identifier ?? ""
    }

    private func assertSnapshots(matching view: AnyView,
                                 name: String?,
                                 isScreen: Bool,
                                 device: ViewImageConfig,
                                 testName: String = #function,
                                 traits: UITraitCollection = .init(),
                                 preferences: SnapshotPreferences) -> String? {
        let matchingView = isScreen ? AnyView(view) : AnyView(view
            .frame(width: device.size?.width)
            .fixedSize(horizontal: false, vertical: true)
        )
        
        return withSnapshotTesting(record: recordMode) {
            verifySnapshot(of: matchingView,
                           as: .prefireImage(preferences: preferences,
                                             layout: isScreen ? .device(config: device) : .sizeThatFits,
                                             traits: traits),
                           named: name,
                           testName: testName)
        }
    }
    
    private func wait(for duration: TimeInterval) {
        let expectation = XCTestExpectation(description: "Wait")
        DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
            expectation.fulfill()
        }
        _ = XCTWaiter.wait(for: [expectation], timeout: duration + 1)
    }
}

private class SnapshotPreferences: @unchecked Sendable {
    var precision: Float = 1
    var perceptualPrecision: Float = 1
    var fulfillmentPublisher: AnyPublisher<Bool, Never>?
}

// MARK: - SnapshotTesting + Extensions

private extension PreviewDevice {
    func snapshotDevice() -> ViewImageConfig? {
        switch rawValue {
        case "iPhone 16", "iPhone 15", "iPhone 14", "iPhone 13", "iPhone 12", "iPhone 11", "iPhone 10":
            return .iPhoneX
        case "iPhone 6", "iPhone 6s", "iPhone 7", "iPhone 8":
            return .iPhone8
        case "iPhone 6 Plus", "iPhone 6s Plus", "iPhone 8 Plus":
            return .iPhone8Plus
        case "iPhone SE (1st generation)", "iPhone SE (2nd generation)":
            return .iPhoneSe
        case "iPad":
            return .iPad10_2
        case "iPad Mini":
            return .iPadMini
        case "iPad Pro 11":
            return .iPadPro11
        case "iPad Pro 12.9":
            return .iPadPro12_9
        default: return nil
        }
    }
}

private extension Snapshotting where Value: SwiftUI.View, Format == UIImage {
    static func prefireImage(drawHierarchyInKeyWindow: Bool = false,
                             preferences: SnapshotPreferences,
                             layout: SwiftUISnapshotLayout = .sizeThatFits,
                             traits: UITraitCollection = .init()) -> Snapshotting {
        let config: ViewImageConfig

        switch layout {
        #if os(iOS) || os(tvOS)
        case let .device(config: deviceConfig):
            config = deviceConfig
        #endif
        case .sizeThatFits:
            // Make sure to use the workaround safe area insets.
            config = .init(safeArea: .one, size: nil, traits: traits)
        case let .fixed(width: width, height: height):
            let size = CGSize(width: width, height: height)
            // Make sure to use the workaround safe area insets.
            config = .init(safeArea: .one, size: size, traits: traits)
        }

        return SimplySnapshotting<UIImage>(pathExtension: "png", diffing: .prefireImage(preferences: preferences, scale: traits.displayScale))
            .asyncPullback { view in
                var config = config

                let controller: UIViewController

                if config.size != nil {
                    controller = UIHostingController(rootView: view)
                } else {
                    let hostingController = UIHostingController(rootView: view)

                    let maxSize = CGSize.zero
                    config.size = hostingController.sizeThatFits(in: maxSize)

                    controller = hostingController
                }
                
                return snapshotView(config: config,
                                    drawHierarchyInKeyWindow: drawHierarchyInKeyWindow,
                                    traits: traits,
                                    view: controller.view,
                                    viewController: controller)
            }
    }
}

private extension Diffing where Value == UIImage {
    static func prefireImage(preferences: SnapshotPreferences, scale: CGFloat?) -> Diffing {
        lazy var originalDiffing = Diffing.image(precision: preferences.precision, perceptualPrecision: preferences.perceptualPrecision, scale: scale)
        return Diffing(toData: { originalDiffing.toData($0) },
                       fromData: { originalDiffing.fromData($0) },
                       diff: { originalDiffing.diff($0, $1) })
    }
}

private extension UIEdgeInsets {
    /// A custom inset that prevents the snapshotting library from rendering the
    /// origin at (10000, 10000) which breaks some of our views such as MessageText.
    static var one: UIEdgeInsets { UIEdgeInsets(top: 1, left: 1, bottom: 1, right: 1) }
}
