import Foundation
import JSONUtilities
import PathKit
import ProjectSpec
import XcodeProj
import Yams

extension Project {

    public func getProjectBuildSettings(config: Config) -> BuildSettings {
        var buildSettings: BuildSettings = [:]

        // set project SDKROOT is a single platform
        if let firstPlatform = targets.first?.platform,
           targets.allSatisfy({ $0.platform == firstPlatform })
        {
            buildSettings["SDKROOT"] = .string(firstPlatform.sdkRoot)
        }

        if let type = config.type, options.settingPresets.applyProject {
            buildSettings += SettingsPresetFile.base.getBuildSettings()
            buildSettings += SettingsPresetFile.config(type).getBuildSettings()
        }

        // apply custom platform version
        for platform in Platform.allCases {
            if let version = options.deploymentTarget.version(for: platform) {
                buildSettings[platform.deploymentTargetSetting] = .string(version.deploymentTarget)
            }
        }

        // Prevent setting presets from overwriting settings in project xcconfig files
        if let configPath = configFiles[config.name] {
            buildSettings = removeConfigFileSettings(from: buildSettings, configPath: configPath)
        }

        buildSettings += getBuildSettings(settings: settings, config: config)

        return buildSettings
    }

    public func getTargetBuildSettings(target: Target, config: Config) -> BuildSettings {
        var buildSettings = BuildSettings()
        
        // list of supported destination sorted by priority
        let specSupportedDestinations = target.supportedDestinations?.sorted(by: { $0.priority < $1.priority }) ?? []
        
        if options.settingPresets.applyTarget {
            let platform: Platform
            
            if target.platform == .auto,
               let firstDestination = specSupportedDestinations.first,
               let firstDestinationPlatform = Platform(rawValue: firstDestination.rawValue) {
                
                platform = firstDestinationPlatform
            } else {
                platform = target.platform
            }
            
            buildSettings += SettingsPresetFile.platform(platform).getBuildSettings()
            buildSettings += SettingsPresetFile.product(target.type).getBuildSettings()
            buildSettings += SettingsPresetFile.productPlatform(target.type, platform).getBuildSettings()
            
            if target.platform == .auto {
                // this fix is necessary because the platform preset overrides the original value
                buildSettings["SDKROOT"] = .string(Platform.auto.rawValue)
            }
            
            if !specSupportedDestinations.isEmpty {
                var supportedPlatforms: [String] = []
                var targetedDeviceFamily: [String] = []
                
                for supportedDestination in specSupportedDestinations {
                    let supportedPlatformBuildSettings = SettingsPresetFile.supportedDestination(supportedDestination).getBuildSettings()
                    buildSettings += supportedPlatformBuildSettings
                    
                    if let value = supportedPlatformBuildSettings?["SUPPORTED_PLATFORMS"]?.stringValue {
                        supportedPlatforms += value.components(separatedBy: " ")
                    }
                    if let value = supportedPlatformBuildSettings?["TARGETED_DEVICE_FAMILY"]?.stringValue {
                        targetedDeviceFamily += value.components(separatedBy: ",")
                    }
                }
                
                buildSettings["SUPPORTED_PLATFORMS"] = .string(supportedPlatforms.joined(separator: " "))
                buildSettings["TARGETED_DEVICE_FAMILY"] = .string(targetedDeviceFamily.joined(separator: ","))
            }
        }
        
        // apply custom platform version
        if let version = target.deploymentTarget {
            if !specSupportedDestinations.isEmpty {
                for supportedDestination in specSupportedDestinations {
                    if let platform = Platform(rawValue: supportedDestination.rawValue) {
                        buildSettings[platform.deploymentTargetSetting] = .string(version.deploymentTarget)
                    }
                }
            } else {
                buildSettings[target.platform.deploymentTargetSetting] = .string(version.deploymentTarget)
            }
        }

        // Prevent setting presets from overrwriting settings in target xcconfig files
        if let configPath = target.configFiles[config.name] {
            buildSettings = removeConfigFileSettings(from: buildSettings, configPath: configPath)
        }
        // Prevent setting presets from overrwriting settings in project xcconfig files
        if let configPath = configFiles[config.name] {
            buildSettings = removeConfigFileSettings(from: buildSettings, configPath: configPath)
        }

        buildSettings += getBuildSettings(settings: target.settings, config: config)

        return buildSettings
    }

    public func getBuildSettings(settings: Settings, config: Config) -> BuildSettings {
        var buildSettings: BuildSettings = [:]

        for group in settings.groups {
            if let settings = settingGroups[group] {
                buildSettings += getBuildSettings(settings: settings, config: config)
            }
        }

        buildSettings += settings.buildSettings

        for (configVariant, settings) in settings.configSettings {
            let isPartialMatch = config.name.lowercased().contains(configVariant.lowercased())
            if isPartialMatch {
                let exactConfig = getConfig(configVariant)
                let matchesExactlyToOtherConfig = exactConfig != nil && exactConfig?.name != config.name
                if !matchesExactlyToOtherConfig {
                    buildSettings += getBuildSettings(settings: settings, config: config)
                }
            }
        }

        return buildSettings
    }

    // combines all levels of a target's settings: target, target config, project, project config
    public func getCombinedBuildSetting(_ setting: String, target: ProjectTarget, config: Config) -> BuildSetting? {
        if let target = target as? Target,
            let value = getTargetBuildSettings(target: target, config: config)[setting] {
            return value
        }
        if let configFilePath = target.configFiles[config.name],
            let value = loadConfigFileBuildSettings(path: configFilePath)?[setting] {
            return value
        }
        if let value = getProjectBuildSettings(config: config)[setting] {
            return value
        }
        if let configFilePath = configFiles[config.name],
            let value = loadConfigFileBuildSettings(path: configFilePath)?[setting] {
            return value
        }
        return nil
    }

    public func getBoolBuildSetting(_ setting: String, target: ProjectTarget, config: Config) -> Bool? {
        getCombinedBuildSetting(setting, target: target, config: config)?.boolValue
    }

    public func targetHasBuildSetting(_ setting: String, target: Target, config: Config) -> Bool {
        getCombinedBuildSetting(setting, target: target, config: config) != nil
    }

    /// Removes values from build settings if they are defined in an xcconfig file
    private func removeConfigFileSettings(from buildSettings: BuildSettings, configPath: String) -> BuildSettings {
        var buildSettings = buildSettings

        if let configSettings = loadConfigFileBuildSettings(path: configPath) {
            for key in configSettings.keys {
                // FIXME: Catch platform specifier. e.g. LD_RUNPATH_SEARCH_PATHS[sdk=iphone*]
                buildSettings.removeValue(forKey: key)
                buildSettings.removeValue(forKey: key.quoted)
            }
        }

        return buildSettings
    }

    /// Returns cached build settings from a config file
    private func loadConfigFileBuildSettings(path: String) -> BuildSettings? {
        let configFilePath = basePath + path
        if let cached = configFileSettings[configFilePath.string] {
            return cached.value
        } else {
            guard let configFile = try? XCConfig(path: configFilePath) else {
                configFileSettings[configFilePath.string] = .nothing
                return nil
            }
            let settings = configFile.flattenedBuildSettings()
            configFileSettings[configFilePath.string] = .cached(settings)
            return settings
        }
    }
}

private enum Cached<T> {
    case cached(T)
    case nothing

    var value: T? {
        switch self {
        case let .cached(value): return value
        case .nothing: return nil
        }
    }
}

// cached flattened xcconfig file settings
private var configFileSettings: [String: Cached<BuildSettings>] = [:]

// cached setting preset settings
private var settingPresetSettings: [String: Cached<BuildSettings>] = [:]

extension SettingsPresetFile {

    public func getBuildSettings() -> BuildSettings? {
        if let cached = settingPresetSettings[path] {
            return cached.value
        }
        let bundlePath = Path(Bundle.main.bundlePath)
        let relativePath = Path("SettingPresets/\(path).yml")
        var possibleSettingsPaths: [Path] = [
            relativePath,
            bundlePath + relativePath,
            bundlePath + "../share/xcodegen/\(relativePath)",
            Path(#file).parent().parent().parent() + relativePath,
        ]

        if let resourcePath = Bundle.main.resourcePath {
            possibleSettingsPaths.append(Path(resourcePath) + relativePath)
        }

        if let symlink = try? (bundlePath + "xcodegen").symlinkDestination() {
            possibleSettingsPaths = [
                symlink.parent() + relativePath,
            ] + possibleSettingsPaths
        }
        if let moduleResourcePath = Bundle.availableModule?.path(forResource: "SettingPresets", ofType: nil) {
            possibleSettingsPaths.append(Path(moduleResourcePath) + "\(path).yml")
        }

        guard let settingsPath = possibleSettingsPaths.first(where: { $0.exists }) else {
            switch self {
            case .base, .config, .platform, .supportedDestination:
                print("No \"\(name)\" settings found")
            case .product, .productPlatform:
                break
            }
            settingPresetSettings[path] = .nothing
            return nil
        }

        guard let dictionary = try? loadYamlDictionary(path: settingsPath) else {
            print("Error parsing \"\(name)\" settings")
            return nil
        }
        let buildSettings: BuildSettings = dictionary.mapValues { BuildSetting(any: $0) }
        settingPresetSettings[path] = .cached(buildSettings)
        return buildSettings
    }
}

private class BundleFinder {}

/// The default SPM generated `Bundle.module` crashes on runtime if there is no .bundle file.
/// Below implementation modified from generated `Bundle.module` code which call `fatalError` if .bundle file not found.
private extension Bundle {
    /// Returns the resource bundle associated with the current Swift module.
    static let availableModule: Bundle? = {
        let bundleName = "XcodeGen_XcodeGenKit"

        let overrides: [URL]
        #if DEBUG
        // The 'PACKAGE_RESOURCE_BUNDLE_PATH' name is preferred since the expected value is a path. The
        // check for 'PACKAGE_RESOURCE_BUNDLE_URL' will be removed when all clients have switched over.
        // This removal is tracked by rdar://107766372.
        if let override = ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_PATH"]
                       ?? ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_URL"] {
            overrides = [URL(fileURLWithPath: override)]
        } else {
            overrides = []
        }
        #else
        overrides = []
        #endif

        let candidates = overrides + [
            // Bundle should be present here when the package is linked into an App.
            Bundle.main.resourceURL,

            // Bundle should be present here when the package is linked into a framework.
            Bundle(for: BundleFinder.self).resourceURL,

            // For command-line tools.
            Bundle.main.bundleURL,
        ]

        for candidate in candidates {
            let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle")
            if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
                return bundle
            }
        }
        return nil
    }()
}
