import Foundation
import SourceKittenFramework
import SwiftSyntax

struct OperatorUsageWhitespaceRule: OptInRule, CorrectableRule, SourceKitFreeRule {
    var configuration = OperatorUsageWhitespaceConfiguration()

    static let description = RuleDescription(
        identifier: "operator_usage_whitespace",
        name: "Operator Usage Whitespace",
        description: "Operators should be surrounded by a single whitespace when they are being used",
        kind: .style,
        nonTriggeringExamples: OperatorUsageWhitespaceRuleExamples.nonTriggeringExamples,
        triggeringExamples: OperatorUsageWhitespaceRuleExamples.triggeringExamples,
        corrections: OperatorUsageWhitespaceRuleExamples.corrections
    )

    func validate(file: SwiftLintFile) -> [StyleViolation] {
        violationRanges(file: file).map { range, _ in
            StyleViolation(ruleDescription: Self.description,
                           severity: configuration.severityConfiguration.severity,
                           location: Location(file: file, byteOffset: range.location))
        }
    }

    private func violationRanges(file: SwiftLintFile) -> [(ByteRange, String)] {
        OperatorUsageWhitespaceVisitor(
            allowedNoSpaceOperators: configuration.allowedNoSpaceOperators
        )
        .walk(file: file, handler: \.violationRanges)
        .filter { byteRange, _ in
            !configuration.skipAlignedConstants || !isAlignedConstant(in: byteRange, file: file)
        }.sorted { lhs, rhs in
            lhs.0.location < rhs.0.location
        }
    }

    func correct(file: SwiftLintFile) -> Int {
        let violatingRanges = violationRanges(file: file)
            .compactMap { byteRange, correction -> (NSRange, String)? in
                guard let range = file.stringView.byteRangeToNSRange(byteRange) else {
                    return nil
                }

                return (range, correction)
            }
            .filter { range, _ in
                file.ruleEnabled(violatingRanges: [range], for: self).isNotEmpty
            }

        var correctedContents = file.contents
        var numberOfCorrections = 0
        for (violatingRange, correction) in violatingRanges.reversed() {
            if let indexRange = correctedContents.nsrangeToIndexRange(violatingRange) {
                correctedContents = correctedContents.replacingCharacters(in: indexRange, with: correction)
                numberOfCorrections += 1
            }
        }
        file.write(correctedContents)
        return numberOfCorrections
    }

    private func isAlignedConstant(in byteRange: ByteRange, file: SwiftLintFile) -> Bool {
        // Make sure we have match with assignment operator and with spaces before it
        guard let matchedString = file.stringView.substringWithByteRange(byteRange) else {
            return false
        }
        let equalityOperatorRegex = regex("\\s+=\\s")

        guard let match = equalityOperatorRegex.firstMatch(
                in: matchedString,
                options: [],
                range: matchedString.fullNSRange),
              match.range == matchedString.fullNSRange
        else {
            return false
        }

        guard let (lineNumber, _) = file.stringView.lineAndCharacter(forByteOffset: byteRange.upperBound),
              case let lineIndex = lineNumber - 1, lineIndex >= 0 else {
            return false
        }

        // Find lines above and below with the same location of =
        let currentLine = file.stringView.lines[lineIndex].content
        let index = currentLine.firstIndex(of: "=")
        guard let offset = index.map({ currentLine.distance(from: currentLine.startIndex, to: $0) }) else {
            return false
        }

        // Look around for assignment operator in lines around
        let lineIndexesAround = (1...configuration.linesLookAround)
            .flatMap { [lineIndex + $0, lineIndex - $0] }

        func isValidIndex(_ idx: Int) -> Bool {
            idx != lineIndex && idx >= 0 && idx < file.stringView.lines.count
        }

        for lineIndex in lineIndexesAround where isValidIndex(lineIndex) {
            let line = file.stringView.lines[lineIndex].content
            guard !line.isEmpty else { continue }
            let index = line.index(line.startIndex,
                                   offsetBy: offset,
                                   limitedBy: line.index(line.endIndex, offsetBy: -1))
            if index.map({ line[$0] }) == "=" {
                return true
            }
        }

        return false
    }
}

private class OperatorUsageWhitespaceVisitor: SyntaxVisitor {
    private let allowedNoSpaceOperators: Set<String>
    private(set) var violationRanges: [(ByteRange, String)] = []

    init(allowedNoSpaceOperators: [String]) {
        self.allowedNoSpaceOperators = Set(allowedNoSpaceOperators)
        super.init(viewMode: .sourceAccurate)
    }

    override func visitPost(_ node: BinaryOperatorExprSyntax) {
        if let violation = violation(operatorToken: node.operator) {
            violationRanges.append(violation)
        }
    }

    override func visitPost(_ node: InitializerClauseSyntax) {
        if let violation = violation(operatorToken: node.equal) {
            violationRanges.append(violation)
        }
    }

    override func visitPost(_ node: TypeInitializerClauseSyntax) {
        if let violation = violation(operatorToken: node.equal) {
            violationRanges.append(violation)
        }
    }

    override func visitPost(_ node: AssignmentExprSyntax) {
        if let violation = violation(operatorToken: node.equal) {
            violationRanges.append(violation)
        }
    }

    override func visitPost(_ node: TernaryExprSyntax) {
        if let violation = violation(operatorToken: node.colon) {
            violationRanges.append(violation)
        }

        if let violation = violation(operatorToken: node.questionMark) {
            violationRanges.append(violation)
        }
    }

    override func visitPost(_ node: UnresolvedTernaryExprSyntax) {
        if let violation = violation(operatorToken: node.colon) {
            violationRanges.append(violation)
        }

        if let violation = violation(operatorToken: node.questionMark) {
            violationRanges.append(violation)
        }
    }

    private func violation(operatorToken: TokenSyntax) -> (ByteRange, String)? {
        guard let previousToken = operatorToken.previousToken(viewMode: .sourceAccurate),
              let nextToken = operatorToken.nextToken(viewMode: .sourceAccurate) else {
            return nil
        }

        let noSpacingBefore = previousToken.trailingTrivia.isEmpty && operatorToken.leadingTrivia.isEmpty
        let noSpacingAfter = operatorToken.trailingTrivia.isEmpty && nextToken.leadingTrivia.isEmpty
        let noSpacing = noSpacingBefore || noSpacingAfter

        let operatorText = operatorToken.text
        if noSpacing, allowedNoSpaceOperators.contains(operatorText) {
            return nil
        }

        let tooMuchSpacingBefore = previousToken.trailingTrivia.containsTooMuchWhitespacing &&
            !operatorToken.leadingTrivia.containsNewlines()
        let tooMuchSpacingAfter = operatorToken.trailingTrivia.containsTooMuchWhitespacing &&
            !operatorToken.trailingTrivia.containsNewlines()

        let tooMuchSpacing = (tooMuchSpacingBefore || tooMuchSpacingAfter) &&
            !operatorToken.leadingTrivia.containsComments &&
            !operatorToken.trailingTrivia.containsComments &&
            !nextToken.leadingTrivia.containsComments

        guard noSpacing || tooMuchSpacing else {
            return nil
        }

        let location = ByteCount(previousToken.endPositionBeforeTrailingTrivia)
        let endPosition = ByteCount(nextToken.positionAfterSkippingLeadingTrivia)
        let range = ByteRange(
            location: location,
            length: endPosition - location
        )

        let correction = allowedNoSpaceOperators.contains(operatorText) ? operatorText : " \(operatorText) "
        return (range, correction)
    }
}

private extension Trivia {
    var containsTooMuchWhitespacing: Bool {
        contains { element in
            guard case let .spaces(spaces) = element, spaces > 1 else {
                return false
            }

            return true
        }
    }

    var containsComments: Bool {
        contains { (element: TriviaPiece) in
            switch element {
            case .blockComment, .docLineComment, .docBlockComment, .lineComment:
                return true
            case .backslashes, .carriageReturnLineFeeds, .carriageReturns, .formfeeds, .newlines, .pounds,
                 .spaces, .tabs, .unexpectedText, .verticalTabs:
                return false
            }
        }
    }
}
