import FilenameMatcher
import Foundation
import SourceKittenFramework

/// An interface for enumerating files that can be linted by SwiftLint.
public protocol LintableFileManager {
    /// Returns all files that can be linted in the specified path. If the path is relative, it will be appended to the
    /// specified root path, or current working directory if no root directory is specified.
    ///
    /// - parameter path:          The path in which lintable files should be found.
    /// - parameter rootDirectory: The parent directory for the specified path. If none is provided, the current working
    ///                            directory will be used.
    /// - parameter excluder:     The excluder used to filter out files that should not be linted.
    ///
    /// - returns: Files to lint.
    func filesToLint(inPath path: URL, excluder: Excluder) -> [URL]

    /// Returns the date when the file at the specified path was last modified. Returns `nil` if the file cannot be
    /// found or its last modification date cannot be determined.
    ///
    /// - parameter path: The file whose modification date should be determined.
    ///
    /// - returns: A date, if one was determined.
    func modificationDate(forFileAtPath path: URL) -> Date?
}

/// An excluder for filtering out files that should not be linted.
public enum Excluder {
    /// Full matching excluder using filename matchers.
    case matching(matchers: [FilenameMatcher])
    /// Prefix-based excluder using path prefixes.
    case byPrefix(prefixes: [String])
    /// An excluder that does not exclude any files.
    case noExclusion

    func excludes(path: URL) -> Bool {
        switch self {
        case let .matching(matchers):
            matchers.contains(where: { $0.match(filename: path.path) })
        case let .byPrefix(prefixes):
            prefixes.contains(where: { path.path.hasPrefix($0) })
        case .noExclusion:
            false
        }
    }
}

#if os(macOS)
extension FileManager: @unchecked @retroactive Sendable {}
#endif

extension FileManager: LintableFileManager {
    private static let enumeratorProperties: Set<URLResourceKey> = [
        .isRegularFileKey,
        .isSymbolicLinkKey,
    ]
    private static let enumeratorOptions: DirectoryEnumerationOptions = [
        .skipsPackageDescendants,
        .skipsSubdirectoryDescendants,
    ]

    public func filesToLint(inPath path: URL, excluder: Excluder) -> [URL] {
        // If path is a file, filter and return it directly.
        if path.isSwiftFile {
            return excluder.excludes(path: path) ? [] : [path]
        }

        // Fast path when there are no exclusions.
        if case .noExclusion = excluder {
            return subpaths(atPath: path.filepath)?.parallelCompactMap { element in
                let absoluteElementPath = element.url(relativeTo: path)
                return absoluteElementPath.isSwiftFile ? absoluteElementPath : nil
            } ?? []
        }

        return collectFiles(atPath: path, excluder: excluder)
    }

    private func collectFiles(atPath absolutePath: URL, excluder: Excluder) -> [URL] {
        let enumerator = enumerator(
            at: absolutePath,
            includingPropertiesForKeys: Array(Self.enumeratorProperties),
            options: Self.enumeratorOptions
        )
        guard let enumerator else {
            return []
        }

        var files = [URL]()
        var directoriesToWalk = [URL]()

        for case var element as URL in enumerator {
            var resourceValues = try? element.resourceValues(forKeys: Self.enumeratorProperties)
            if resourceValues?.isSymbolicLink == true {
                element = element.standardizedFileURL
                if excluder.excludes(path: element) {
                    continue
                }
                element.resolveSymlinksInPath()
                resourceValues = try? element.resourceValues(forKeys: Self.enumeratorProperties)
            }
            if resourceValues?.isRegularFile == true {
                if element.pathExtension == "swift", !excluder.excludes(path: element) {
                    files.append(element)
                }
            } else if resourceValues != nil, !excluder.excludes(path: element) {
                directoriesToWalk.append(element)
            }
        }

        return files + directoriesToWalk.parallelFlatMap { collectFiles(atPath: $0, excluder: excluder) }
    }

    public func modificationDate(forFileAtPath path: URL) -> Date? {
        (try? attributesOfItem(atPath: path.filepath))?[.modificationDate] as? Date
    }
}
