//
//  LinterCacheTests.swift
//  SwiftLint
//
//  Created by Marcelo Fabri on 12/27/16.
//  Copyright © 2016 Realm. All rights reserved.
//

import Foundation
@testable import SwiftLintFramework
import XCTest

private struct CacheTestHelper {
    fileprivate let configuration: Configuration

    private let ruleList: RuleList
    private let ruleDescription: RuleDescription
    private let cache: LinterCache

    private var fileManager: TestFileManager {
        // swiftlint:disable:next force_cast
        return cache.fileManager as! TestFileManager
    }

    fileprivate init(dict: [String: Any], cache: LinterCache) {
        ruleList = RuleList(rules: RuleWithLevelsMock.self)
        ruleDescription = ruleList.list.values.first!.description
        configuration = Configuration(dict: dict, ruleList: ruleList)!
        self.cache = cache
    }

    fileprivate func makeViolations(file: String) -> [StyleViolation] {
        touch(file: file)
        return [
            StyleViolation(ruleDescription: ruleDescription,
                           severity: .warning,
                           location: Location(file: file, line: 10, character: 2),
                           reason: "Something is not right."),
            StyleViolation(ruleDescription: ruleDescription,
                           severity: .error,
                           location: Location(file: file, line: 5, character: nil),
                           reason: "Something is wrong.")
        ]
    }

    fileprivate func makeConfig(dict: [String: Any]) -> Configuration {
        return Configuration(dict: dict, ruleList: ruleList)!
    }

    fileprivate func touch(file: String) {
        fileManager.stubbedModificationDateByPath[file] = Date()
    }

    fileprivate func remove(file: String) {
        fileManager.stubbedModificationDateByPath[file] = nil
    }

    fileprivate func fileCount() -> Int {
        return fileManager.stubbedModificationDateByPath.count
    }
}

private class TestFileManager: LintableFileManager {
    fileprivate func filesToLint(inPath: String, rootDirectory: String? = nil) -> [String] {
        return []
    }

    fileprivate var stubbedModificationDateByPath = [String: Date]()

    fileprivate func modificationDate(forFileAtPath path: String) -> Date? {
        return stubbedModificationDateByPath[path]
    }
}

class LinterCacheTests: XCTestCase {

    // MARK: Test Helpers

    private var cache = LinterCache(fileManager: TestFileManager())

    private func makeCacheTestHelper(dict: [String: Any]) -> CacheTestHelper {
        return CacheTestHelper(dict: dict, cache: cache)
    }

    private func cacheAndValidate(violations: [StyleViolation], forFile: String, configuration: Configuration,
                                  file: StaticString = #file, line: UInt = #line) {
        cache.cache(violations: violations, forFile: forFile, configuration: configuration)
        cache = cache.flushed()
        XCTAssertEqual(cache.violations(forFile: forFile, configuration: configuration)!,
                       violations, file: file, line: line)
    }

    private func cacheAndValidateNoViolationsTwoFiles(configuration: Configuration,
                                                      file: StaticString = #file, line: UInt = #line) {
        let (file1, file2) = ("file1.swift", "file2.swift")
        // swiftlint:disable:next force_cast
        let fileManager = cache.fileManager as! TestFileManager
        fileManager.stubbedModificationDateByPath = [file1: Date(), file2: Date()]

        cacheAndValidate(violations: [], forFile: file1, configuration: configuration, file: file, line: line)
        cacheAndValidate(violations: [], forFile: file2, configuration: configuration, file: file, line: line)
    }

    private func validateNewConfigDoesntHitCache(dict: [String: Any], initialConfig: Configuration,
                                                 file: StaticString = #file, line: UInt = #line) {
        let newConfig = Configuration(dict: dict)!
        let (file1, file2) = ("file1.swift", "file2.swift")

        XCTAssertNil(cache.violations(forFile: file1, configuration: newConfig), file: file, line: line)
        XCTAssertNil(cache.violations(forFile: file2, configuration: newConfig), file: file, line: line)

        XCTAssertEqual(cache.violations(forFile: file1, configuration: initialConfig)!, [], file: file, line: line)
        XCTAssertEqual(cache.violations(forFile: file2, configuration: initialConfig)!, [], file: file, line: line)
    }

    // MARK: Cache Initialization

    func testInitThrowsWhenUsingInvalidCacheFormat() {
        let cache = [["version": "0.1.0"]]
        checkError(LinterCacheError.invalidFormat) {
            _ = try LinterCache(cache: cache)
        }
    }

    func testSaveThrowsWithNoLocation() throws {
        let cache = try LinterCache(cache: [:])
        checkError(LinterCacheError.noLocation) {
            try cache.save()
        }
    }

    func testInitSucceeds() {
        XCTAssertNotNil(try? LinterCache(cache: [:]))
    }

    // MARK: Cache Reuse

    // Two subsequent lints with no changes reuses cache
    func testUnchangedFilesReusesCache() {
        let helper = makeCacheTestHelper(dict: ["whitelist_rules": ["mock"]])
        let file = "foo.swift"
        let violations = helper.makeViolations(file: file)

        cacheAndValidate(violations: violations, forFile: file, configuration: helper.configuration)
        helper.touch(file: file)

        XCTAssertNil(cache.violations(forFile: file, configuration: helper.configuration))
    }

    func testConfigFileReorderedReusesCache() {
        let helper = makeCacheTestHelper(dict: ["whitelist_rules": ["mock"], "disabled_rules": []])
        let file = "foo.swift"
        let violations = helper.makeViolations(file: file)

        cacheAndValidate(violations: violations, forFile: file, configuration: helper.configuration)
        let configuration2 = helper.makeConfig(dict: ["disabled_rules": [], "whitelist_rules": ["mock"]])
        XCTAssertEqual(cache.violations(forFile: file, configuration: configuration2)!, violations)
    }

    func testConfigFileWhitespaceAndCommentsChangedOrAddedOrRemovedReusesCache() throws {
        let helper = makeCacheTestHelper(dict: try YamlParser.parse("whitelist_rules:\n  - mock"))
        let file = "foo.swift"
        let violations = helper.makeViolations(file: file)

        cacheAndValidate(violations: violations, forFile: file, configuration: helper.configuration)
        let configuration2 = helper.makeConfig(dict: ["disabled_rules": [], "whitelist_rules": ["mock"]])
        XCTAssertEqual(cache.violations(forFile: file, configuration: configuration2)!, violations)
        let configYamlWithComment = try YamlParser.parse("# comment1\nwhitelist_rules:\n  - mock # comment2")
        let configuration3 = helper.makeConfig(dict: configYamlWithComment)
        XCTAssertEqual(cache.violations(forFile: file, configuration: configuration3)!, violations)
        XCTAssertEqual(cache.violations(forFile: file, configuration: helper.configuration)!, violations)
    }

    func testConfigFileUnrelatedKeysChangedOrAddedOrRemovedReusesCache() {
        let helper = makeCacheTestHelper(dict: ["whitelist_rules": ["mock"], "reporter": "json"])
        let file = "foo.swift"
        let violations = helper.makeViolations(file: file)

        cacheAndValidate(violations: violations, forFile: file, configuration: helper.configuration)
        let configuration2 = helper.makeConfig(dict: ["whitelist_rules": ["mock"], "reporter": "xcode"])
        XCTAssertEqual(cache.violations(forFile: file, configuration: configuration2)!, violations)
        let configuration3 = helper.makeConfig(dict: ["whitelist_rules": ["mock"]])
        XCTAssertEqual(cache.violations(forFile: file, configuration: configuration3)!, violations)
    }

    // MARK: Sing-File Cache Invalidation

    // Two subsequent lints with a file touch in between causes just that one
    // file to be re-linted, with the cache used for all other files
    func testChangedFileCausesJustThatFileToBeLintWithCacheUsedForAllOthers() {
        let helper = makeCacheTestHelper(dict: ["whitelist_rules": ["mock"], "reporter": "json"])
        let (file1, file2) = ("file1.swift", "file2.swift")
        let violations1 = helper.makeViolations(file: file1)
        let violations2 = helper.makeViolations(file: file2)

        cacheAndValidate(violations: violations1, forFile: file1, configuration: helper.configuration)
        cacheAndValidate(violations: violations2, forFile: file2, configuration: helper.configuration)
        helper.touch(file: file2)
        XCTAssertEqual(cache.violations(forFile: file1, configuration: helper.configuration)!, violations1)
        XCTAssertNil(cache.violations(forFile: file2, configuration: helper.configuration))
    }

    func testFileRemovedPreservesThatFileInTheCacheAndDoesntCauseAnyOtherFilesToBeLinted() {
        let helper = makeCacheTestHelper(dict: ["whitelist_rules": ["mock"], "reporter": "json"])
        let (file1, file2) = ("file1.swift", "file2.swift")
        let violations1 = helper.makeViolations(file: file1)
        let violations2 = helper.makeViolations(file: file2)

        cacheAndValidate(violations: violations1, forFile: file1, configuration: helper.configuration)
        cacheAndValidate(violations: violations2, forFile: file2, configuration: helper.configuration)
        XCTAssertEqual(helper.fileCount(), 2)
        helper.remove(file: file2)
        XCTAssertEqual(cache.violations(forFile: file1, configuration: helper.configuration)!, violations1)
        XCTAssertEqual(helper.fileCount(), 1)
    }

    // MARK: All-File Cache Invalidation

    func testCustomRulesChangedOrAddedOrRemovedCausesAllFilesToBeReLinted() {
        let initialConfig = Configuration(
            dict: [
                "whitelist_rules": ["custom_rules", "rule1"],
                "custom_rules": ["rule1": ["regex": "([n,N]inja)"]]
            ],
            ruleList: RuleList(rules: CustomRules.self)
        )!
        cacheAndValidateNoViolationsTwoFiles(configuration: initialConfig)

        // Change
        validateNewConfigDoesntHitCache(
            dict: [
                "whitelist_rules": ["custom_rules", "rule1"],
                "custom_rules": ["rule1": ["regex": "([n,N]injas)"]]
            ],
            initialConfig: initialConfig
        )

        // Addition
        validateNewConfigDoesntHitCache(
            dict: [
                "whitelist_rules": ["custom_rules", "rule1"],
                "custom_rules": ["rule1": ["regex": "([n,N]injas)"], "rule2": ["regex": "([k,K]ittens)"]]
            ],
            initialConfig: initialConfig
        )

        // Removal
        validateNewConfigDoesntHitCache(dict: ["whitelist_rules": ["custom_rules"]], initialConfig: initialConfig)
    }

    func testDisabledRulesChangedOrAddedOrRemovedCausesAllFilesToBeReLinted() {
        let initialConfig = Configuration(dict: ["disabled_rules": ["nesting"]])!
        cacheAndValidateNoViolationsTwoFiles(configuration: initialConfig)

        // Change
        validateNewConfigDoesntHitCache(dict: ["disabled_rules": ["todo"]], initialConfig: initialConfig)
        // Addition
        validateNewConfigDoesntHitCache(dict: ["disabled_rules": ["nesting", "todo"]], initialConfig: initialConfig)
        // Removal
        validateNewConfigDoesntHitCache(dict: ["disabled_rules": []], initialConfig: initialConfig)
    }

    func testOptInRulesChangedOrAddedOrRemovedCausesAllFilesToBeReLinted() {
        let initialConfig = Configuration(dict: ["opt_in_rules": ["attributes"]])!
        cacheAndValidateNoViolationsTwoFiles(configuration: initialConfig)

        // Change
        validateNewConfigDoesntHitCache(dict: ["opt_in_rules": ["empty_count"]], initialConfig: initialConfig)
        // Rules addition
        validateNewConfigDoesntHitCache(dict: ["opt_in_rules": ["attributes", "empty_count"]],
                                        initialConfig: initialConfig)
        // Removal
        validateNewConfigDoesntHitCache(dict: ["opt_in_rules": []], initialConfig: initialConfig)
    }

    func testEnabledRulesChangedOrAddedOrRemovedCausesAllFilesToBeReLinted() {
        let initialConfig = Configuration(dict: ["enabled_rules": ["attributes"]])!
        cacheAndValidateNoViolationsTwoFiles(configuration: initialConfig)

        // Change
        validateNewConfigDoesntHitCache(dict: ["enabled_rules": ["empty_count"]], initialConfig: initialConfig)
        // Addition
        validateNewConfigDoesntHitCache(dict: ["enabled_rules": ["attributes", "empty_count"]],
                                        initialConfig: initialConfig)
        // Removal
        validateNewConfigDoesntHitCache(dict: ["enabled_rules": []], initialConfig: initialConfig)
    }

    func testWhitelistRulesChangedOrAddedOrRemovedCausesAllFilesToBeReLinted() {
        let initialConfig = Configuration(dict: ["whitelist_rules": ["nesting"]])!
        cacheAndValidateNoViolationsTwoFiles(configuration: initialConfig)

        // Change
        validateNewConfigDoesntHitCache(dict: ["whitelist_rules": ["todo"]], initialConfig: initialConfig)
        // Addition
        validateNewConfigDoesntHitCache(dict: ["whitelist_rules": ["nesting", "todo"]], initialConfig: initialConfig)
        // Removal
        validateNewConfigDoesntHitCache(dict: ["whitelist_rules": []], initialConfig: initialConfig)
    }

    func testRuleConfigurationChangedOrAddedOrRemovedCausesAllFilesToBeReLinted() {
        let initialConfig = Configuration(dict: ["line_length": 120])!
        cacheAndValidateNoViolationsTwoFiles(configuration: initialConfig)

        // Change
        validateNewConfigDoesntHitCache(dict: ["line_length": 100], initialConfig: initialConfig)
        // Addition
        validateNewConfigDoesntHitCache(dict: ["line_length": 100, "number_separator": ["minimum_length": 5]],
                                        initialConfig: initialConfig)
        // Removal
        validateNewConfigDoesntHitCache(dict: [:], initialConfig: initialConfig)
    }

    func testSwiftVersionChangedRemovedCausesAllFilesToBeReLinted() {
        let fileManager = TestFileManager()
        cache = LinterCache(fileManager: fileManager)
        let helper = makeCacheTestHelper(dict: [:])
        let file = "foo.swift"
        let violations = helper.makeViolations(file: file)

        cacheAndValidate(violations: violations, forFile: file, configuration: helper.configuration)
        let thisSwiftVersionCache = cache

        let differentSwiftVersion: SwiftVersion = (SwiftVersion.current >= .four) ? .three : .four
        cache = LinterCache(fileManager: fileManager, swiftVersion: differentSwiftVersion)

        XCTAssertNotNil(thisSwiftVersionCache.violations(forFile: file, configuration: helper.configuration))
        XCTAssertNil(cache.violations(forFile: file, configuration: helper.configuration))
    }

    func testDetectSwiftVersion() {
        #if swift(>=4.1.0)
            let version = "4.1.0"
        #elseif swift(>=4.0.3)
            let version = "4.0.3"
        #elseif swift(>=4.0.2)
            let version = "4.0.2"
        #elseif swift(>=4.0.1)
            let version = "4.0.1"
        #elseif swift(>=4.0.0)
            let version = "4.0.0"
        #elseif swift(>=3.2.3)
            let version = "4.0.3" // Since we can't pass SWIFT_VERSION=3 to sourcekit, it returns 4.0.3
        #elseif swift(>=3.2.2)
            let version = "4.0.2" // Since we can't pass SWIFT_VERSION=3 to sourcekit, it returns 4.0.2
        #elseif swift(>=3.2.1)
            let version = "4.0.1" // Since we can't pass SWIFT_VERSION=3 to sourcekit, it returns 4.0.1
        #else // if swift(>=3.2.0)
            let version = "4.0.0" // Since we can't pass SWIFT_VERSION=3 to sourcekit, it returns 4.0.0
        #endif
        XCTAssertEqual(SwiftVersion.current.rawValue, version)
    }

    // MARK: JSON output

    func testCacheToJSONDoesntCrash() {
        // swiftlint:disable line_length
        let k1 = "[\"/SwiftLint/source\",[[\"block_based_kvo\",\"warning\"],[\"class_delegate_protocol\",\"warning\"],[\"closing_brace\",\"warning\"],[\"closure_parameter_position\",\"warning\"],[\"colon\",\"warning, flexible_right_spacing: false, apply_to_dictionaries: true\"],[\"comma\",\"warning\"],[\"compiler_protocol_init\",\"warning\"],[\"control_statement\",\"warning\"],[\"custom_rules\",\"\"],[\"cyclomatic_complexity\",\"warning: 10, error: 20, ignores_case_statements: false\"],[\"discarded_notification_center_observer\",\"warning\"],[\"discouraged_direct_init\",\"warning, types: [\"Bundle\", \"Bundle.init\", \"UIDevice\", \"UIDevice.init\"]\"],[\"dynamic_inline\",\"error\"],[\"empty_enum_arguments\",\"warning\"],[\"empty_parameters\",\"warning\"]]]"

        let k2 = "[\"/SwiftLint/Source\",[[\"block_based_kvo\",\"warning\"],[\"class_delegate_protocol\",\"warning\"],[\"closing_brace\",\"warning\"],[\"closure_parameter_position\",\"warning\"],[\"colon\",\"warning, flexible_right_spacing: false, apply_to_dictionaries: true\"],[\"comma\",\"warning\"],[\"compiler_protocol_init\",\"warning\"],[\"control_statement\",\"warning\"],[\"custom_rules\",\"\"],[\"cyclomatic_complexity\",\"warning: 10, error: 20, ignores_case_statements: false\"],[\"discarded_notification_center_observer\",\"warning\"],[\"discouraged_direct_init\",\"warning, types: [\"Bundle\", \"Bundle.init\", \"UIDevice\", \"UIDevice.init\"]\"],[\"dynamic_inline\",\"error\"],[\"empty_enum_arguments\",\"warning\"],[\"empty_parameters\",\"warning\"]]]"
        // swiftlint:enable line_length

        let dict = [k1: "test", k2: "test2"]

        XCTAssertNoThrow(try dict.toJSON())
    }
}
