import Foundation
import SourceKittenFramework
import SwiftLintFramework
import SwiftSyntax
import Testing

#if os(Windows)
private struct PlatformInfo {
    struct DefaultProperties {
        let versionXCTest: String
        let versionSwiftTesting: String
        let swiftFlags: [String]?
    }

    let defaults: DefaultProperties
}

extension PlatformInfo.DefaultProperties: Decodable {
    enum CodingKeys: String, CodingKey {
        case versionXCTest = "XCTEST_VERSION"
        case versionSwiftTesting = "SWIFT_TESTING_VERSION"
        case swiftFlags = "SWIFTC_FLAGS"
    }
}

extension PlatformInfo: Decodable {
    enum CodingKeys: String, CodingKey {
        case defaults = "DefaultProperties"
    }
}

private let info: PlatformInfo = {
    let sdk = sdkPath().url(directoryHint: .isDirectory)
        .deletingLastPathComponent()
        .deletingLastPathComponent()
        .deletingLastPathComponent()
        .appending(path: "Info.plist", directoryHint: .notDirectory)
    guard let data = try? Data(contentsOf: sdk),
          let info = try? PropertyListDecoder().decode(PlatformInfo.self, from: data) else {
        fatalError("invalid platform SDK - couldn't decode \(sdk.path)")
    }
    return info
}()
#endif

// swiftlint:disable file_length

private let violationMarker = "↓"
private let violationMarkerChar = violationMarker.first!

private extension SwiftLintFile {
    static func testFile(withContents contents: String, persistToDisk: Bool = false) -> SwiftLintFile {
        if persistToDisk {
            let url = URL.temporaryDirectory
                .appending(path: UUID().uuidString, directoryHint: .notDirectory)
                .appendingPathExtension("swift")
            _ = try? contents.data(using: .utf8)!.write(to: url)
            return SwiftLintFile(path: url, isTestFile: true)!
        }
        return SwiftLintFile(contents: contents, isTestFile: true)
    }

    func makeCompilerArguments() -> [String] {
        let sdk = sdkPath()
        let frameworks = sdk.url(directoryHint: .isDirectory)
            .deletingLastPathComponent()
            .deletingLastPathComponent()
            .appending(path: "Library", directoryHint: .isDirectory)
            .appending(path: "Frameworks", directoryHint: .isDirectory)
            .filepath

        let arguments = [
            "-F", frameworks,
            "-sdk", sdk,
            "-Xfrontend", "-enable-objc-interop",
            "-j4",
            path!.filepath,
        ]
#if os(Windows)
        let XCTestPath = sdk.url(directoryHint: .isDirectory)
            .deletingLastPathComponent()
            .deletingLastPathComponent()
            .appending(path: "Library", directoryHint: .isDirectory)
            .appending(path: "XCTest-\(info.defaults.versionXCTest)", directoryHint: .isDirectory)
            .appending(path: "usr", directoryHint: .isDirectory)
            .appending(path: "lib", directoryHint: .isDirectory)
            .appending(path: "swift", directoryHint: .isDirectory)
            .appending(path: "windows", directoryHint: .isDirectory)
            .path
        return ["-I", XCTestPath] + arguments
#else
        return arguments
#endif
    }
}

public let allRuleIdentifiers = Set(RuleRegistry.shared.list.list.keys)

public extension Configuration {
    func applyingConfiguration(from example: Example) -> Configuration {
        guard let exampleConfiguration = example.configuration,
              case let .onlyConfiguration(onlyRules) = rulesMode,
              let firstRule = (onlyRules.first { $0 != "superfluous_disable_command" }),
              case let configDict: [_: any Sendable] = ["only_rules": onlyRules, firstRule: exampleConfiguration],
              let typedConfiguration = try? Configuration(dict: configDict) else { return self }
        return merged(withChild: typedConfiguration, rootDirectory: rootDirectory)
    }
}

public func violations(_ example: Example,
                       config inputConfig: Configuration = Configuration.default,
                       requiresFileOnDisk: Bool = false) -> [StyleViolation] {
    let config = inputConfig.applyingConfiguration(from: example)
    let stringStrippingMarkers = example.removingViolationMarkers()
    guard requiresFileOnDisk else {
        let file = SwiftLintFile.testFile(withContents: stringStrippingMarkers.code)
        let storage = RuleStorage()
        let linter = Linter(file: file, configuration: config).collect(into: storage)
        return linter.styleViolations(using: storage)
    }

    let file = SwiftLintFile.testFile(withContents: stringStrippingMarkers.code, persistToDisk: true)
    let storage = RuleStorage()
    let collector = Linter(file: file, configuration: config, compilerArguments: file.makeCompilerArguments())
    let linter = collector.collect(into: storage)
    return linter.styleViolations(using: storage).withoutFiles()
}

public extension Collection where Element == String {
    func violations(config: Configuration = Configuration.default, requiresFileOnDisk: Bool = false)
        -> [StyleViolation] {
            map { SwiftLintFile.testFile(withContents: $0, persistToDisk: requiresFileOnDisk) }
                .violations(config: config, requiresFileOnDisk: requiresFileOnDisk)
    }

    func corrections(config: Configuration = Configuration.default, requiresFileOnDisk: Bool = false) -> [String: Int] {
        map { SwiftLintFile.testFile(withContents: $0, persistToDisk: requiresFileOnDisk) }
            .corrections(config: config, requiresFileOnDisk: requiresFileOnDisk)
    }
}

public extension Collection where Element: SwiftLintFile {
    func violations(config: Configuration = Configuration.default, requiresFileOnDisk: Bool = false)
        -> [StyleViolation] {
            let storage = RuleStorage()
            let violations = map({ file in
                Linter(file: file, configuration: config,
                       compilerArguments: requiresFileOnDisk ? file.makeCompilerArguments() : [])
            }).map({ linter in
                linter.collect(into: storage)
            }).flatMap({ linter in
                linter.styleViolations(using: storage)
            })
            return requiresFileOnDisk ? violations.withoutFiles() : violations
    }

    func corrections(config: Configuration = Configuration.default, requiresFileOnDisk: Bool = false) -> [String: Int] {
        let storage = RuleStorage()
        var corrections = [String: Int]()
        for file in self {
            let linter = Linter(
                file: file,
                configuration: config,
                compilerArguments: requiresFileOnDisk ? file.makeCompilerArguments() : []
            )
            let collectedLinter = linter.collect(into: storage)
            for (ruleName, numberOfCorrections) in collectedLinter.correct(using: storage) {
                corrections[ruleName] = numberOfCorrections
            }
        }
        return corrections
    }
}

private extension Collection where Element == StyleViolation {
    func withoutFiles() -> [StyleViolation] {
        map { violation in
            let locationWithoutFile = Location(file: nil, line: violation.location.line,
                                               character: violation.location.character)
            return violation.with(location: locationWithoutFile)
        }
    }
}

public extension Collection where Element == Example {
    /// Returns a dictionary with SwiftLint violation markers (↓) removed from keys.
    ///
    /// - returns: A new `Array`.
    func removingViolationMarkers() -> [Element] {
        map { $0.removingViolationMarkers() }
    }
}

private func cleanedContentsAndMarkerOffsets(from contents: String) -> (String, [AbsolutePosition]) {
    var markerOffsets = [AbsolutePosition]()
    var cleanedContents = ""
    cleanedContents.reserveCapacity(contents.count)

    var offset = AbsolutePosition(utf8Offset: 0)
    for char in contents {
        if char == violationMarkerChar {
            markerOffsets.append(offset)
        } else {
            cleanedContents.append(char)
            offset = offset.advanced(by: char.utf8.count)
        }
    }

    return (cleanedContents, markerOffsets)
}

private func render(violations: [StyleViolation], in contents: String) -> String {
    var contents = StringView(contents).lines.map(\.content)
    for violation in violations.sorted(by: { $0.location > $1.location }) {
        guard let line = violation.location.line,
              let character = violation.location.character else { continue }

        let message = String(repeating: " ", count: character - 1) + "^ " + [
            "\(violation.severity.rawValue): ",
            "\(violation.ruleName) Violation: ",
            violation.reason,
            " (\(violation.ruleIdentifier))",
        ].joined()
        if line >= contents.count {
            contents.append(message)
        } else {
            contents.insert(message, at: line)
        }
    }
    return """
        ```
        \(contents.joined(separator: "\n"))
        ```
        """
}

private func render(locations: [Location], in contents: String) -> String {
    var contents = StringView(contents).lines.map(\.content)
    for location in locations.sorted(by: > ) {
        guard let line = location.line, let character = location.character else { continue }
        let content = NSMutableString(string: contents[line - 1])
        content.insert("↓", at: character - 1)
        contents[line - 1] = content.bridge()
    }
    return """
        ```
        \(contents.joined(separator: "\n"))
        ```
        """
}

private extension Configuration {
    func assertCorrection(_ before: Example, expected: Example) {
        let (cleanedBefore, _) = cleanedContentsAndMarkerOffsets(from: before.code)
        let file = SwiftLintFile.testFile(withContents: cleanedBefore, persistToDisk: true)
        // expectedLocations are needed to create before call `correct()`
        let includeCompilerArguments = rules.contains(where: { $0 is any AnalyzerRule })
        let compilerArguments = includeCompilerArguments ? file.makeCompilerArguments() : []
        let storage = RuleStorage()
        let collector = Linter(file: file, configuration: self, compilerArguments: compilerArguments)
        let linter = collector.collect(into: storage)
        let corrections = linter.correct(using: storage)
        #expect(
            corrections.count >= (before.code != expected.code ? 1 : 0),
            "Expected at least one correction to be applied when before and expected code differs",
            sourceLocation: before.sourceLocation
        )
        #expect(
            file.contents == expected.code,
            "Expected fixed code to match the suggested correction",
            sourceLocation: before.sourceLocation
        )
        let path = file.path!
        do {
            let corrected = try String(contentsOf: path, encoding: .utf8)
            #expect(
                corrected == expected.code,
                "Expected content of fixed file to match the suggested correction",
                sourceLocation: before.sourceLocation
            )
        } catch {
            Testing.Issue.record(
                Comment(rawValue: "Couldn't read file at path '\(path)': \(error)"),
                sourceLocation: before.sourceLocation
            )
        }
    }
}

private extension String {
    func toStringLiteral() -> String {
        "\"" + replacingOccurrences(of: "\n", with: "\\n") + "\""
    }
}

public func makeConfig(_ ruleConfiguration: Any?,
                       _ identifier: String,
                       skipDisableCommandTests: Bool = false) -> Configuration? {
    let superfluousDisableCommandRuleIdentifier = SuperfluousDisableCommandRule.identifier
    let identifiers: Set<String> = skipDisableCommandTests ? [identifier]
        : [identifier, superfluousDisableCommandRuleIdentifier]

    if let ruleConfiguration, let ruleType = RuleRegistry.shared.rule(forID: identifier) {
        // The caller has provided a custom configuration for the rule under test
        return (try? ruleType.init(configuration: ruleConfiguration)).flatMap { configuredRule in
            let rules = skipDisableCommandTests ? [configuredRule] : [configuredRule, SuperfluousDisableCommandRule()]
            return Configuration(
                rulesMode: .onlyConfiguration(identifiers),
                allRulesWrapped: rules.map { ($0, false) }
            )
        }
    }
    return Configuration(rulesMode: .onlyConfiguration(identifiers))
}

private func testCorrection(_ correction: (Example, Example),
                            configuration: Configuration,
                            testMultiByteOffsets: Bool) {
#if os(Linux)
    guard correction.0.testOnLinux else {
        return
    }
#endif
#if os(Windows)
    guard correction.0.testOnWindows else {
        return
    }
#endif
    var config = configuration
    if let correctionConfiguration = correction.0.configuration,
       case let .onlyConfiguration(onlyRules) = configuration.rulesMode,
       let ruleToConfigure = (onlyRules.first { $0 != SuperfluousDisableCommandRule.identifier }),
       case let configDict: [_: any Sendable] = ["only_rules": onlyRules, ruleToConfigure: correctionConfiguration],
       let typedConfiguration = try? Configuration(dict: configDict) {
        config = configuration.merged(withChild: typedConfiguration, rootDirectory: configuration.rootDirectory)
    }

    config.assertCorrection(correction.0, expected: correction.1)
    if testMultiByteOffsets, correction.0.testMultiByteOffsets {
        config.assertCorrection(addEmoji(correction.0), expected: addEmoji(correction.1))
    }
}

private func addEmoji(_ example: Example) -> Example {
    example.with(code: "/* 👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦 */\n\(example.code)")
}

private func addShebang(_ example: Example) -> Example {
    example.with(code: "#!/usr/bin/env swift\n\(example.code)")
}

public var isRunningWithBazel: Bool { FileManager.default.currentDirectoryPath.contains("bazel-out") }

public func verifyCorrections(_ ruleDescription: RuleDescription,
                              config: Configuration,
                              disableCommands: [String],
                              testMultiByteOffsets: Bool,
                              parserDiagnosticsDisabledForTests: Bool = true) {
    let ruleDescription = ruleDescription.focused()
    SwiftLintCore.$parserDiagnosticsDisabledForTests.withValue(parserDiagnosticsDisabledForTests) {
        // corrections
        ruleDescription.corrections.forEach {
            testCorrection($0, configuration: config, testMultiByteOffsets: testMultiByteOffsets)
        }
        // make sure strings that don't trigger aren't corrected
        ruleDescription.nonTriggeringExamples.forEach {
            testCorrection(($0, $0), configuration: config, testMultiByteOffsets: testMultiByteOffsets)
        }

        // "disable" commands do not correct
        ruleDescription.corrections.forEach { before, _ in
            for command in disableCommands {
                let beforeDisabled = command + before.code
                let expectedCleaned = before.with(code: cleanedContentsAndMarkerOffsets(from: beforeDisabled).0)
                config.assertCorrection(expectedCleaned, expected: expectedCleaned)
            }
        }
    }
}

public func verifyRule(_ ruleDescription: RuleDescription,
                       ruleConfiguration: Any? = nil,
                       commentDoesntViolate: Bool = true,
                       stringDoesntViolate: Bool = true,
                       skipCommentTests: Bool = false,
                       skipStringTests: Bool = false,
                       skipDisableCommandTests: Bool = false,
                       testMultiByteOffsets: Bool = true,
                       testShebang: Bool = true,
                       fileID: String = #fileID,
                       file: StaticString = #filePath,
                       line: UInt = #line) {
    guard ruleDescription.minSwiftVersion <= .current else {
        return
    }

    guard let config = makeConfig(
            ruleConfiguration,
            ruleDescription.identifier,
            skipDisableCommandTests: skipDisableCommandTests) else {
        Testing.Issue.record(
            Comment(rawValue: "Failed to create configuration"),
            sourceLocation: SourceLocation(fileID: fileID, filePath: "\(file)", line: Int(line), column: 1)
        )
        return
    }

    let disableCommands: [String]
    if skipDisableCommandTests {
        disableCommands = []
    } else {
        disableCommands = ruleDescription.allIdentifiers.map { "// swiftlint:disable \($0)\n" }
    }

    verifyLint(
        ruleDescription,
        config: config,
        commentDoesntViolate: commentDoesntViolate,
        stringDoesntViolate: stringDoesntViolate,
        skipCommentTests: skipCommentTests,
        skipStringTests: skipStringTests,
        disableCommands: disableCommands,
        testMultiByteOffsets: testMultiByteOffsets,
        testShebang: testShebang
    )
    verifyCorrections(
        ruleDescription,
        config: config,
        disableCommands: disableCommands,
        testMultiByteOffsets: testMultiByteOffsets
    )
}

// swiftlint:disable:next function_body_length cyclomatic_complexity
func verifyLint(_ ruleDescription: RuleDescription,
                config: Configuration,
                commentDoesntViolate: Bool = true,
                stringDoesntViolate: Bool = true,
                skipCommentTests: Bool = false,
                skipStringTests: Bool = false,
                disableCommands: [String] = [],
                testMultiByteOffsets: Bool = true,
                testShebang: Bool = true) {
    func verify(triggers: [Example], nonTriggers: [Example]) {
        verifyExamples(
            triggers: triggers,
            nonTriggers: nonTriggers,
            configuration: config,
            requiresFileOnDisk: ruleDescription.requiresFileOnDisk
        )
    }
    func makeViolations(_ example: Example) -> [StyleViolation] {
        violations(example, config: config, requiresFileOnDisk: ruleDescription.requiresFileOnDisk)
    }

    let focusedRuleDescription = ruleDescription.focused()
    let (triggers, nonTriggers) = (
        focusedRuleDescription.triggeringExamples,
        focusedRuleDescription.nonTriggeringExamples
    )
    verify(triggers: triggers, nonTriggers: nonTriggers)

    if testMultiByteOffsets {
        verify(
            triggers: triggers.filter(\.testMultiByteOffsets).map(addEmoji),
            nonTriggers: nonTriggers.filter(\.testMultiByteOffsets).map(addEmoji)
        )
    }

    if testShebang {
        verify(
            triggers: triggers.filter(\.testMultiByteOffsets).map(addShebang),
            nonTriggers: nonTriggers.filter(\.testMultiByteOffsets).map(addShebang)
        )
    }

    // Comment doesn't violate
    if !skipCommentTests {
        for example in triggers.filter(\.testWrappingInComment) {
            let wrapped = example.with(code: "/*\n  " + example.code + "\n */")
            let violations = makeViolations(wrapped)
            if commentDoesntViolate {
                #expect(
                    violations.isEmpty,
                    "Expected no violations when code is nested inside a comment",
                    sourceLocation: example.sourceLocation
                )
            } else {
                #expect(
                    violations.count == 1,
                    "Expected exaclty one violation when code is nested inside a comment",
                    sourceLocation: example.sourceLocation
                )
            }
        }
    }

    // String doesn't violate
    if !skipStringTests {
        for example in triggers.filter(\.testWrappingInString) {
            let wrapped = example.with(code: example.code.toStringLiteral())
            let violations = makeViolations(wrapped)
            if stringDoesntViolate {
                #expect(
                    violations.isEmpty,
                    "Expected no violations when code is nested inside a string literal",
                    sourceLocation: example.sourceLocation
                )
            } else {
                #expect(
                    violations.count == 1,
                    "Expected exaclty one violation when code is nested inside a string literal",
                    sourceLocation: example.sourceLocation
                )
            }
        }
    }

    // Disabled rule doesn't violate and disable command isn't superfluous
    for command in disableCommands {
        let disabledTriggers = triggers
            .filter(\.testDisableCommand)
            .map { $0.with(code: command + $0.code) }

        for trigger in disabledTriggers {
            let violationsPartitionedByType = makeViolations(trigger)
                .partitioned { $0.ruleIdentifier == SuperfluousDisableCommandRule.identifier }

            #expect(
                violationsPartitionedByType.first.isEmpty,
                "Violation(s) still triggered although rule was disabled",
                sourceLocation: trigger.sourceLocation
            )
            #expect(
                violationsPartitionedByType.second.isEmpty,
                "Disable command was superfluous since no violations(s) triggered",
                sourceLocation: trigger.sourceLocation
            )
        }
    }

    // Severity can be changed
    let ruleType = RuleRegistry.shared.rule(forID: ruleDescription.identifier)
    if ruleType?.init().configuration is (any SeverityBasedRuleConfiguration),
        let example = triggers.first(where: { $0.configuration == nil }) {
        let withWarning = Example(code: example.code, configuration: ["severity": "warning"])
        #expect(
            violations(withWarning, config: config).allSatisfy { $0.severity == .warning },
            "Violation severity cannot be changed to warning",
            sourceLocation: example.sourceLocation
        )

        let withError = Example(code: example.code, configuration: ["severity": "error"])
        #expect(
            violations(withError, config: config).allSatisfy { $0.severity == .error },
            "Violation severity cannot be changed to error",
            sourceLocation: example.sourceLocation
        )
    }
}

// swiftlint:disable:next function_body_length
private func verifyExamples(triggers: [Example],
                            nonTriggers: [Example],
                            configuration config: Configuration,
                            requiresFileOnDisk: Bool) {
    // Non-triggering examples don't violate
    for nonTrigger in nonTriggers {
        let unexpectedViolations = violations(
            nonTrigger,
            config: config,
            requiresFileOnDisk: requiresFileOnDisk
        )
        if unexpectedViolations.isEmpty { continue }
        let nonTriggerWithViolations = render(violations: unexpectedViolations, in: nonTrigger.code)
        Testing.Issue.record(
            "nonTriggeringExample violated: \n\(nonTriggerWithViolations)",
            sourceLocation: nonTrigger.sourceLocation
        )
    }

    // Triggering examples violate
    for trigger in triggers {
        let triggerViolations = violations(
            trigger,
            config: config,
            requiresFileOnDisk: requiresFileOnDisk
        )

        // Triggering examples with violation markers violate at the marker's location
        let (cleanTrigger, markerOffsets) = cleanedContentsAndMarkerOffsets(from: trigger.code)
        if markerOffsets.isEmpty {
            if triggerViolations.isEmpty {
                Testing.Issue.record(
                    "Triggering example did not violate in: \n```\n\(trigger.code)\n```",
                    sourceLocation: trigger.sourceLocation
                )
            }
            continue
        }
        let file = SwiftLintFile.testFile(withContents: cleanTrigger)
        let expectedLocations = markerOffsets.map { Location(file: file, position: $0) }

        // Assert violations on unexpected location
        let violationsAtUnexpectedLocation = triggerViolations
            .filter { !expectedLocations.contains($0.location) }
        if !violationsAtUnexpectedLocation.isEmpty {
            Testing.Issue.record(
                """
                Triggering example violated at unexpected location \
                '\(render(violations: violationsAtUnexpectedLocation, in: cleanTrigger))'
                """,
                sourceLocation: trigger.sourceLocation
            )
        }

        // Assert locations missing violation
        let violatedLocations = triggerViolations.map(\.location)
        let locationsWithoutViolation = expectedLocations
            .filter { !violatedLocations.contains($0) }
        if !locationsWithoutViolation.isEmpty {
            Testing.Issue.record(
                """
                Triggering example did not violate at expected location \
                '\(render(locations: locationsWithoutViolation, in: cleanTrigger))'
                """,
                sourceLocation: trigger.sourceLocation
            )
        }

        #expect(
            triggerViolations.count == expectedLocations.count,
            sourceLocation: trigger.sourceLocation
        )
        for (triggerViolation, expectedLocation) in zip(triggerViolations, expectedLocations) {
            #expect(
                triggerViolation.location == expectedLocation,
                "'\(trigger)' violation didn't match expected location.",
                sourceLocation: trigger.sourceLocation
            )
        }
    }
}

// file and line parameters are first so we can use trailing closure syntax with the closure
public func checkError<T: Error & Equatable>(sourceLocation: Testing.SourceLocation = #_sourceLocation,
                                             _ error: T,
                                             closure: () throws -> Void) {
    do {
        try closure()
        Testing.Issue.record("No error caught", sourceLocation: sourceLocation)
    } catch let rError as T {
        #expect(
            error == rError,
            "Wrong error caught. Got \(rError) but was expecting \(error)",
            sourceLocation: sourceLocation
        )
    } catch {
        Testing.Issue.record("Wrong error caught", sourceLocation: sourceLocation)
    }
}

private struct FocusedRuleDescription {
    let nonTriggeringExamples: [Example]
    let triggeringExamples: [Example]
    let corrections: [Example: Example]

    init(rule: RuleDescription) {
        let nonTriggering = rule.nonTriggeringExamples.filter(\.isFocused)
        let triggering = rule.triggeringExamples.filter(\.isFocused)
        let corrections = rule.corrections.filter { key, value in key.isFocused || value.isFocused }
        let anyFocused = nonTriggering.isNotEmpty || triggering.isNotEmpty || corrections.isNotEmpty

        if anyFocused {
            self.nonTriggeringExamples = nonTriggering
            self.triggeringExamples = triggering
            self.corrections = corrections
#if DISABLE_FOCUSED_EXAMPLES
            (nonTriggering + triggering + corrections.values).forEach { example in
                Testing.Issue.record("Focused examples are disabled", sourceLocation: example.sourceLocation)
            }
#endif
        } else {
            self.nonTriggeringExamples = rule.nonTriggeringExamples
            self.triggeringExamples = rule.triggeringExamples
            self.corrections = rule.corrections
        }
    }
}

private extension RuleDescription {
    func focused() -> FocusedRuleDescription {
        FocusedRuleDescription(rule: self)
    }
}

package extension [any Rule] {
    var customRules: CustomRules? {
        first(where: { $0 is CustomRules }) as? CustomRules
    }
}

package extension Example {
    var sourceLocation: Testing.SourceLocation {
        SourceLocation(
            fileID: fileID,
            filePath: String(describing: file),
            line: Int(line),
            column: 1
        )
    }
}
