import Foundation
import SourceKittenFramework

/// The configuration struct for SwiftLint. User-defined in the `.swiftlint.yml` file, drives the behavior of SwiftLint.
public struct Configuration {
    // MARK: - Properties: Static
    /// The default Configuration resulting from an empty configuration file.
    public static var `default`: Self {
        // This is realized via a getter to account for differences of the current working directory
        Self()
    }

    /// The default file name to look for user-defined configurations.
    public static let defaultFileName = ".swiftlint.yml"

    // MARK: Public Instance
    /// The paths that should be included when linting
    public private(set) var includedPaths: [URL]

    /// The paths that should be excluded when linting
    public private(set) var excludedPaths: [URL]

    /// The style to use when indenting Swift source code.
    public let indentation: IndentationStyle

    /// The threshold for the number of warnings to tolerate before treating the lint as having failed.
    public let warningThreshold: Int?

    /// The identifier for the `Reporter` to use to report style violations.
    public let reporter: String?

    /// The location of the persisted cache to use with this configuration.
    public let cachePath: String?

    /// Allow or disallow SwiftLint to exit successfully when passed only ignored or unlintable files.
    public let allowZeroLintableFiles: Bool

    /// Treat warnings as errors.
    public let strict: Bool

    /// Treat errors as warnings.
    public let lenient: Bool

    /// The path to read a baseline from.
    public let baseline: URL?

    /// The path to write a baseline to.
    public let writeBaseline: URL?

    /// Check for updates.
    public let checkForUpdates: Bool

    /// This value is `true` iff the `--config` parameter was used to specify (a) configuration file(s)
    /// In particular, this means that the value is also `true` if the `--config` parameter
    /// was used to explicitly specify the default `.swiftlint.yml` as the configuration file
    public private(set) var basedOnCustomConfigurationFiles = false

    // MARK: Public Computed
    /// All rules enabled in this configuration
    public var rules: [any Rule] { rulesWrapper.resultingRules }

    /// The root directory is the directory that included & excluded paths relate to.
    /// By default, the root directory is the current working directory,
    /// but during some merging algorithms it may be used differently.
    /// The rootDirectory also serves as the stopping point when searching for nested configs along the file hierarchy.
    public var rootDirectory: URL { fileGraph.rootDirectory }

    /// The rules mode used for this configuration.
    public var rulesMode: RulesMode { rulesWrapper.mode }

    // MARK: Internal Instance
    internal var fileGraph: FileGraph
    internal private(set) var rulesWrapper: RulesWrapper
    internal var computedCacheDescription: String?

    // MARK: - Initializers: Internal
    /// Initialize with all properties
    internal init(
        rulesWrapper: RulesWrapper,
        fileGraph: FileGraph,
        includedPaths: [URL],
        excludedPaths: [URL],
        indentation: IndentationStyle,
        warningThreshold: Int?,
        reporter: String?,
        cachePath: String?,
        allowZeroLintableFiles: Bool,
        strict: Bool,
        lenient: Bool,
        baseline: URL?,
        writeBaseline: URL?,
        checkForUpdates: Bool
    ) {
        self.rulesWrapper = rulesWrapper
        self.fileGraph = fileGraph
        self.includedPaths = includedPaths
        self.excludedPaths = excludedPaths
        self.indentation = indentation
        self.warningThreshold = warningThreshold
        self.reporter = reporter
        self.cachePath = cachePath
        self.allowZeroLintableFiles = allowZeroLintableFiles
        self.strict = strict
        self.lenient = lenient
        self.baseline = baseline
        self.writeBaseline = writeBaseline
        self.checkForUpdates = checkForUpdates
    }

    /// Creates a Configuration by copying an existing configuration.
    ///
    /// - parameter copying:    The existing configuration to copy.
    internal init(copying configuration: Self) {
        rulesWrapper = configuration.rulesWrapper
        fileGraph = configuration.fileGraph
        includedPaths = configuration.includedPaths
        excludedPaths = configuration.excludedPaths
        indentation = configuration.indentation
        warningThreshold = configuration.warningThreshold
        reporter = configuration.reporter
        basedOnCustomConfigurationFiles = configuration.basedOnCustomConfigurationFiles
        cachePath = configuration.cachePath
        allowZeroLintableFiles = configuration.allowZeroLintableFiles
        strict = configuration.strict
        lenient = configuration.lenient
        baseline = configuration.baseline
        writeBaseline = configuration.writeBaseline
        checkForUpdates = configuration.checkForUpdates
    }

    /// Creates a `Configuration` by specifying its properties directly,
    /// except that rules are still to be synthesized from rulesMode, ruleList & allRulesWrapped
    /// and a check against the pinnedVersion is performed if given.
    ///
    /// - parameter rulesMode:              The `RulesMode` for this configuration.
    /// - parameter allRulesWrapped:        The rules with their own configurations already applied.
    /// - parameter ruleList:               The list of all rules. Used for alias resolving and as a fallback
    ///                                     if `allRulesWrapped` is nil.
    /// - parameter filePath                The underlying file graph. If `nil` is specified, a empty file graph
    ///                                     with the current working directory as the `rootDirectory` will be used.
    /// - parameter includedPaths:          Included paths to lint.
    /// - parameter excludedPaths:          Excluded paths to not lint.
    /// - parameter indentation:            The style to use when indenting Swift source code.
    /// - parameter warningThreshold:       The threshold for the number of warnings to tolerate before treating the
    ///                                     lint as having failed.
    /// - parameter reporter:               The identifier for the `Reporter` to use to report style violations.
    /// - parameter cachePath:              The location of the persisted cache to use with this configuration.
    /// - parameter pinnedVersion:          The SwiftLint version defined in this configuration.
    /// - parameter allowZeroLintableFiles: Allow SwiftLint to exit successfully when passed ignored or unlintable
    ///                                     files.
    /// - parameter strict:                 Treat warnings as errors.
    /// - parameter lenient:                Treat errors as warnings.
    /// - parameter baseline:               The path to read a baseline from.
    /// - parameter writeBaseline:          The path to write a baseline to.
    /// - parameter checkForUpdates:        Check for updates to SwiftLint.
    package init(
        rulesMode: RulesMode = .defaultConfiguration(disabled: [], optIn: []),
        allRulesWrapped: [ConfigurationRuleWrapper]? = nil,
        ruleList: RuleList = RuleRegistry.shared.list,
        fileGraph: FileGraph? = nil,
        includedPaths: [URL] = [],
        excludedPaths: [URL] = [],
        indentation: IndentationStyle = .default,
        warningThreshold: Int? = nil,
        reporter: String? = nil,
        cachePath: String? = nil,
        pinnedVersion: String? = nil,
        allowZeroLintableFiles: Bool = false,
        strict: Bool = false,
        lenient: Bool = false,
        baseline: URL? = nil,
        writeBaseline: URL? = nil,
        checkForUpdates: Bool = false
    ) {
        if let pinnedVersion, pinnedVersion != Version.current.value {
            queuedPrintError(
                "warning: Currently running SwiftLint \(Version.current.value) but " +
                "configuration specified version \(pinnedVersion)."
            )
            exit(2)
        }

        self.init(
            rulesWrapper: RulesWrapper(
                mode: rulesMode,
                allRulesWrapped: allRulesWrapped ?? (try? ruleList.allRulesWrapped()) ?? [],
                aliasResolver: { ruleList.identifier(for: $0) ?? $0 }
            ),
            fileGraph: fileGraph ?? FileGraph(rootDirectory: URL.cwd),
            includedPaths: includedPaths,
            excludedPaths: excludedPaths,
            indentation: indentation,
            warningThreshold: warningThreshold,
            reporter: reporter,
            cachePath: cachePath,
            allowZeroLintableFiles: allowZeroLintableFiles,
            strict: strict,
            lenient: lenient,
            baseline: baseline,
            writeBaseline: writeBaseline,
            checkForUpdates: checkForUpdates
        )
    }

    // MARK: Public
    /// Creates a `Configuration` with convenience parameters.
    ///
    /// - parameter configurationFiles:         The path on disk to one or multiple configuration files. If this array
    ///                                         is empty, the default `.swiftlint.yml` file will be used.
    /// - parameter enableAllRules:             Enable all available rules.
    /// - parameter cachePath:                  The location of the persisted cache to use whith this configuration.
    /// - parameter ignoreParentAndChildConfigs:If `true`, child and parent config references will be ignored.
    /// - parameter mockedNetworkResults:       For testing purposes only. Instead of loading the specified urls,
    ///                                         the mocked value will be used. Example: ["http://mock.com": "content"]
    /// - parameter useDefaultConfigOnFailure:  If this value is specified, it will override the normal behavior.
    ///                                         This is only intended for tests checking whether invalid configs fail.
    public init(
        configurationFiles: [URL], // No default value here to avoid ambiguous Configuration() initializer
        enableAllRules: Bool = false,
        onlyRule: [String] = [],
        cachePath: String? = nil,
        ignoreParentAndChildConfigs: Bool = false,
        mockedNetworkResults: [String: String] = [:],
        useDefaultConfigOnFailure: Bool? = nil // swiftlint:disable:this discouraged_optional_boolean
    ) {
        // Handle mocked network results if needed
        Self.FileGraph.FilePath.mockedNetworkResults = mockedNetworkResults
        defer {
            if !mockedNetworkResults.isEmpty {
                Self.FileGraph.FilePath.deleteGitignoreAndSwiftlintCache()
            }
        }

        // Store whether there are custom configuration files; use default config file name if there are none
        let hasCustomConfigurationFiles: Bool = configurationFiles.isNotEmpty
        let configurationFiles = configurationFiles.isEmpty
            ? [Self.defaultFileName.url()]
            : configurationFiles
        defer { basedOnCustomConfigurationFiles = hasCustomConfigurationFiles }

        let currentWorkingDirectory = URL.cwd
        let rulesMode: RulesMode = if enableAllRules {
            .allCommandLine
        } else if onlyRule.isNotEmpty {
            .onlyCommandLine(Set(onlyRule))
        } else {
            .defaultConfiguration(disabled: [], optIn: [])
        }

        // Try obtaining cached config
        let cacheIdentifier = "\(currentWorkingDirectory) - \(configurationFiles)"
        if let cachedConfig = Self.getCached(forIdentifier: cacheIdentifier) {
            self.init(copying: cachedConfig)
            return
        }

        // Try building configuration via the file graph
        do {
            var fileGraph = FileGraph(
                commandLineChildConfigs: configurationFiles,
                rootDirectory: currentWorkingDirectory,
                ignoreParentAndChildConfigs: ignoreParentAndChildConfigs
            )
            let resultingConfiguration = try fileGraph.resultingConfiguration(
                enableAllRules: enableAllRules,
                onlyRule: onlyRule,
                cachePath: cachePath
            )

            self.init(copying: resultingConfiguration)
            self.fileGraph = fileGraph
            setCached(forIdentifier: cacheIdentifier)
        } catch {
            if case Issue.initialFileNotFound = error, !hasCustomConfigurationFiles {
                // The initial configuration file wasn't found, but the user didn't explicitly specify one
                // Don't handle as error. Instead, silently fall back to default.
                self.init(rulesMode: rulesMode, cachePath: cachePath)
                return
            }
            if useDefaultConfigOnFailure ?? !hasCustomConfigurationFiles {
                // No files were explicitly specified, so maybe the user doesn't want a config at all -> warn
                queuedPrintError(
                    "\(Issue.wrap(error: error).localizedDescription) – Falling back to default configuration"
                )
                self.init(rulesMode: rulesMode, cachePath: cachePath)
            } else {
                // Files that were explicitly specified could not be loaded -> fail
                queuedPrintError(Issue.wrap(error: error).asError.localizedDescription)
                queuedFatalError("Could not read configuration")
            }
        }
    }

    // MARK: - Methods: Internal
    mutating func makeIncludedAndExcludedPaths(relativeTo newBasePath: URL) {
        includedPaths = includedPaths.map { $0.relative(to: newBasePath) }
        excludedPaths = excludedPaths.map { $0.relative(to: newBasePath) }
    }
}

// MARK: - Hashable
extension Configuration: Hashable {
    public func hash(into hasher: inout Hasher) {
        hasher.combine(includedPaths)
        hasher.combine(excludedPaths)
        hasher.combine(indentation)
        hasher.combine(warningThreshold)
        hasher.combine(reporter)
        hasher.combine(allowZeroLintableFiles)
        hasher.combine(strict)
        hasher.combine(lenient)
        hasher.combine(baseline)
        hasher.combine(writeBaseline)
        hasher.combine(checkForUpdates)
        hasher.combine(basedOnCustomConfigurationFiles)
        hasher.combine(cachePath)
        hasher.combine(rules.map { type(of: $0).identifier })
        hasher.combine(fileGraph)
    }

    public static func == (lhs: Configuration, rhs: Configuration) -> Bool {
        lhs.includedPaths == rhs.includedPaths &&
            lhs.excludedPaths == rhs.excludedPaths &&
            lhs.indentation == rhs.indentation &&
            lhs.warningThreshold == rhs.warningThreshold &&
            lhs.reporter == rhs.reporter &&
            lhs.basedOnCustomConfigurationFiles == rhs.basedOnCustomConfigurationFiles &&
            lhs.cachePath == rhs.cachePath &&
            lhs.rules == rhs.rules &&
            lhs.fileGraph == rhs.fileGraph &&
            lhs.allowZeroLintableFiles == rhs.allowZeroLintableFiles &&
            lhs.strict == rhs.strict &&
            lhs.lenient == rhs.lenient &&
            lhs.baseline == rhs.baseline &&
            lhs.writeBaseline == rhs.writeBaseline &&
            lhs.checkForUpdates == rhs.checkForUpdates &&
            lhs.rulesMode == rhs.rulesMode
    }
}

// MARK: - CustomStringConvertible
extension Configuration: CustomStringConvertible {
    public var description: String {
        "Configuration: \n"
            + "- Indentation Style: \(indentation)\n"
            + "- Included Paths: \(includedPaths)\n"
            + "- Excluded Paths: \(excludedPaths)\n"
            + "- Warning Threshold: \(warningThreshold as Optional)\n"
            + "- Root Directory: \(rootDirectory as Optional)\n"
            + "- Reporter: \(reporter ?? "default")\n"
            + "- Cache Path: \(cachePath as Optional)\n"
            + "- Computed Cache Description: \(computedCacheDescription as Optional)\n"
            + "- Rules: \(rules.map { type(of: $0).identifier })"
    }
}
