import CollectionConcurrencyKit
import Foundation
import SourceKittenFramework

private actor CounterActor {
    private var count = 0

    func next() -> Int {
        count += 1
        return count
    }
}

private func readFilesFromScriptInputFiles() throws(SwiftLintError) -> [SwiftLintFile] {
    let count = try fileCount(from: "SCRIPT_INPUT_FILE_COUNT")
    return (0..<count).compactMap { fileNumber in
        do {
            let environment = ProcessInfo.processInfo.environment
            let variable = "SCRIPT_INPUT_FILE_\(fileNumber)"
            guard let path = environment[variable] else {
                throw SwiftLintError.usageError(description: "Environment variable not set: \(variable)")
            }
            let pathURL = URL(filePath: path)
            if pathURL.isSwiftFile {
                return SwiftLintFile(pathDeferringReading: pathURL)
            }
            return nil
        } catch {
            queuedPrintError(String(describing: error))
            return nil
        }
    }
}

private func readFilesFromScriptInputFileLists() throws(SwiftLintError) -> [SwiftLintFile] {
    let count = try fileCount(from: "SCRIPT_INPUT_FILE_LIST_COUNT")
    return (0..<count).flatMap { fileNumber in
        var filesToLint: [SwiftLintFile] = []
        do {
            let environment = ProcessInfo.processInfo.environment
            let variable = "SCRIPT_INPUT_FILE_LIST_\(fileNumber)"
            guard let path = environment[variable] else {
                throw SwiftLintError.usageError(description: "Environment variable not set: \(variable)")
            }
            let pathURL = URL(filePath: path)
            if pathURL.pathExtension == "xcfilelist" {
                guard let textContents = try? String(contentsOf: pathURL, encoding: .utf8) else {
                    throw SwiftLintError.usageError(description: "Could not read file list at: \(path)")
                }
                textContents.enumerateLines { line, _ in
                    let lineURL = URL(filePath: line)
                    if lineURL.isSwiftFile {
                        filesToLint.append(SwiftLintFile(pathDeferringReading: lineURL))
                    }
                }
            }
        } catch {
            queuedPrintError(String(describing: error))
        }
        return filesToLint
    }
}

private func fileCount(from envVar: String) throws(SwiftLintError) -> Int {
    guard let countString = ProcessInfo.processInfo.environment[envVar] else {
        throw .usageError(description: "\(envVar) variable not set")
    }
    guard let count = Int(countString) else {
        throw .usageError(description: "\(envVar) did not specify a number")
    }
    return count
}

#if os(Linux) || os(Windows)
private func autoreleasepool<T>(block: () -> T) -> T { block() }
#endif

extension Configuration {
    func visitLintableFiles(with visitor: LintableFilesVisitor, storage: RuleStorage) async throws -> [SwiftLintFile] {
        let files = try Signposts.record(name: "Configuration.VisitLintableFiles.GetFiles") {
            try getFiles(with: visitor)
        }
        let groupedFiles = try Signposts.record(name: "Configuration.VisitLintableFiles.GroupFiles") {
            try groupFiles(files, visitor: visitor)
        }
        let lintersForFile = Signposts.record(name: "Configuration.VisitLintableFiles.LintersForFile") {
            groupedFiles.map { file in
                linters(for: [file.key: file.value], visitor: visitor)
            }
        }
        let duplicateFileNames = Signposts.record(name: "Configuration.VisitLintableFiles.DuplicateFileNames") {
            lintersForFile.map(\.duplicateFileNames)
        }
        let collected = await Signposts.record(name: "Configuration.VisitLintableFiles.Collect") {
            await zip(lintersForFile, duplicateFileNames).asyncMap { linters, duplicateFileNames in
                await collect(linters: linters, visitor: visitor, storage: storage,
                              duplicateFileNames: duplicateFileNames)
            }
        }
        let result = await Signposts.record(name: "Configuration.VisitLintableFiles.Visit") {
            await collected.asyncMap { linters, duplicateFileNames in
                await visit(linters: linters, visitor: visitor, duplicateFileNames: duplicateFileNames)
            }
        }
        return result.flatMap(\.self)
    }

    private func groupFiles(_ files: [SwiftLintFile], visitor: LintableFilesVisitor) throws(SwiftLintError)
        -> [Configuration: [SwiftLintFile]] {
        if files.isEmpty, !visitor.allowZeroLintableFiles {
            throw .usageError(
                description: """
                    No lintable files found at paths: \
                    '\(visitor.options.paths.map(\.relativePath).joined(separator: ", "))'
                    """
            )
        }

        return files.parallelFilterGroup { file in
            let fileConfiguration = configuration(for: file)
            let fileConfigurationRootPath = fileConfiguration.rootDirectory

            // Files whose configuration specifies they should be excluded will be skipped
            let shouldSkip = fileConfiguration.excludedPaths.contains { excludedRelativePath in
                let excludedPath = fileConfigurationRootPath.appending(path: excludedRelativePath.relativePath)
                let filePathComponents = file.path?.pathComponents ?? []
                let excludedPathComponents = excludedPath.pathComponents
                return filePathComponents.starts(with: excludedPathComponents)
            }

            return shouldSkip ? nil : fileConfiguration
        }
    }

    private func outputFilename(for path: URL, duplicateFileNames: Set<String>) -> String {
        let basename = path.lastPathComponent
        if !duplicateFileNames.contains(basename) {
            return basename
        }
        return path.relativeDisplayPath
    }

    private func linters(for filesPerConfiguration: [Configuration: [SwiftLintFile]],
                         visitor: LintableFilesVisitor) -> [Linter] {
        let fileCount = filesPerConfiguration.reduce(0) { $0 + $1.value.count }

        var linters = [Linter]()
        linters.reserveCapacity(fileCount)
        for (config, files) in filesPerConfiguration {
            let newConfig: Configuration
            if visitor.cache != nil {
                newConfig = config.withPrecomputedCacheDescription()
            } else {
                newConfig = config
            }
            linters += files.map { visitor.linter(forFile: $0, configuration: newConfig) }
        }
        return linters
    }

    private func collect(linters: [Linter],
                         visitor: LintableFilesVisitor,
                         storage: RuleStorage,
                         duplicateFileNames: Set<String>) async -> ([CollectedLinter], Set<String>) {
        let counter = CounterActor()
        let total = linters.filter(\.isCollecting).count
        let progress = ProgressBar(count: total)
        if visitor.options.progress, total > 0 {
            await progress.initialize()
        }
        let collect = { (linter: Linter) -> CollectedLinter? in
            let skipFile = visitor.shouldSkipFile(atPath: linter.file.path)
            if !visitor.options.quiet, linter.isCollecting {
                if visitor.options.progress {
                    await progress.printNext()
                } else if let filePath = linter.file.path {
                    let outputFilename = self.outputFilename(for: filePath, duplicateFileNames: duplicateFileNames)
                    let collected = await counter.next()
                    if skipFile {
                        Issue.genericWarning(
                            """
                            Skipping '\(outputFilename)' (\(collected)/\(total)) \
                            because its compiler arguments could not be found
                            """
                        ).print()
                    } else {
                        queuedPrintError("Collecting '\(outputFilename)' (\(collected)/\(total))")
                    }
                }
            }

            guard !skipFile else {
                return nil
            }

            return autoreleasepool {
                linter.collect(into: storage)
            }
        }

        let collectedLinters = await visitor.parallel ?
            linters.concurrentCompactMap(collect) :
            linters.asyncCompactMap(collect)
        return (collectedLinters, duplicateFileNames)
    }

    private func visit(linters: [CollectedLinter],
                       visitor: LintableFilesVisitor,
                       duplicateFileNames: Set<String>) async -> [SwiftLintFile] {
        let counter = CounterActor()
        let progress = ProgressBar(count: linters.count)
        if visitor.options.progress {
            await progress.initialize()
        }
        let visit = { (linter: CollectedLinter) -> SwiftLintFile in
            if !visitor.options.quiet {
                if visitor.options.progress {
                    await progress.printNext()
                } else if let filePath = linter.file.path {
                    let outputFilename = self.outputFilename(for: filePath, duplicateFileNames: duplicateFileNames)
                    let visited = await counter.next()
                    queuedPrintError(
                        "\(visitor.options.capitalizedVerb) '\(outputFilename)' (\(visited)/\(linters.count))"
                    )
                }
            }

            await Signposts.record(name: "Configuration.Visit", span: .file(linter.file.path?.filepath ?? "")) {
                await visitor.block(linter)
            }
            return linter.file
        }
        return await visitor.parallel ?
            linters.concurrentMap(visit) :
            linters.asyncMap(visit)
    }

    fileprivate func getFiles(with visitor: LintableFilesVisitor) throws(SwiftLintError) -> [SwiftLintFile] {
        let options = visitor.options
        if options.useSTDIN {
            let stdinData = FileHandle.standardInput.readDataToEndOfFile()
            if let stdinString = String(data: stdinData, encoding: .utf8) {
                return [SwiftLintFile(contents: stdinString)]
            }
            throw .usageError(description: "stdin isn't a UTF8-encoded string")
        }
        if options.useScriptInputFiles || options.useScriptInputFileLists {
            let files = try options.useScriptInputFiles
                ? readFilesFromScriptInputFiles()
                : readFilesFromScriptInputFileLists()
            guard options.forceExclude else {
                return files
            }
            let scriptInputPaths = files.compactMap(\.path)
            return filteredPaths(in: scriptInputPaths, excludeByPrefix: visitor.options.useExcludingByPrefix)
                .map(SwiftLintFile.init(pathDeferringReading:))
        }
        if !options.quiet {
            let filesInfo: String
            if options.paths.isEmpty || options.paths == [URL.cwd] {
                filesInfo = "in current working directory"
            } else {
                filesInfo = "at paths \(options.paths.map(\.relativePath).joined(separator: ", "))"
            }

            queuedPrintError("\(options.capitalizedVerb) Swift files \(filesInfo)")
        }
        return visitor.options.paths.flatMap {
            self.lintableFiles(
                inPath: $0,
                forceExclude: visitor.options.forceExclude,
                excludeByPrefix: visitor.options.useExcludingByPrefix
            )
        }
    }

    func visitLintableFiles(options: LintOrAnalyzeOptions,
                            cache: LinterCache? = nil,
                            storage: RuleStorage,
                            visitorBlock: @escaping (CollectedLinter) async -> Void) async throws -> [SwiftLintFile] {
        let visitor = try LintableFilesVisitor.create(options, cache: cache,
                                                      allowZeroLintableFiles: allowZeroLintableFiles,
                                                      block: visitorBlock)
        return try await visitLintableFiles(with: visitor, storage: storage)
    }

    // MARK: LintOrAnalyze Command

    init(options: LintOrAnalyzeOptions) {
        self.init(
            configurationFiles: options.configurationFiles,
            enableAllRules: options.enableAllRules,
            onlyRule: options.onlyRule,
            cachePath: options.cachePath
        )
    }
}

private struct DuplicateCollector {
    var all = Set<String>()
    var duplicates = Set<String>()
}

private extension Collection where Element == Linter {
    var duplicateFileNames: Set<String> {
        let collector = reduce(into: DuplicateCollector()) { result, linter in
            if let filename = linter.file.path?.lastPathComponent {
                if result.all.contains(filename) {
                    result.duplicates.insert(filename)
                }

                result.all.insert(filename)
            }
        }
        return collector.duplicates
    }
}
