#!/usr/bin/xcrun --sdk macosx swift

// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
//
// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference
// is canges to the localized usage regex
//
// stringlint:disable

import Foundation

extension ProjectState {
    /// Adding `// stringlint:disable` to the top of a source file (before imports) or after a string will mean that file/line gets
    /// ignored by this script (good for some things like the auto-generated emoji strings or debug strings)
    static let lintSuppression: String = "stringlint:disable"
    static let primaryLocalisationFile: String = "en"
    static let validLocalisationSuffixes: Set<String> = ["Localizable.strings"]
    static let validSourceSuffixes: Set<String> = [".swift", ".m"]
    static let excludedPaths: Set<String> = [
        "build/",                   // Files under the build folder (CI)
        "Pods/",                    // The pods folder
        "Protos/",                  // The protobuf files
        ".xcassets/",               // Asset bundles
        ".app/",                    // App build directories
        ".appex/",                  // Extension build directories
        "tests/",                   // Exclude test directories
        "_SharedTestUtilities/",    // Exclude shared test directory
        "external/"                 // External dependencies
    ]
    static let excludedPhrases: Set<String> = [ "", " ", ",", ", ", "null" ]
    static let excludedUnlocalisedStringLineMatching: Set<MatchType> = [
        .contains(ProjectState.lintSuppression),
        .prefix("#import"),
        .prefix("@available("),
        .contains("fatalError("),
        .contains("precondition("),
        .contains("preconditionFailure("),
        .contains("print("),
        .contains("NSLog("),
        .contains("SNLog("),
        .contains("SNLogNotTests("),
        .contains("owsFailDebug("),
        .contains("#imageLiteral(resourceName:"),
        .contains("UIImage(named:"),
        .contains("UIImage(systemName:"),
        .contains("[UIImage imageNamed:"),
        .contains("UIFont(name:"),
        .contains(".dateFormat ="),
        .contains(".accessibilityLabel ="),
        .contains(".accessibilityValue ="),
        .contains(".accessibilityIdentifier ="),
        .contains("accessibilityIdentifier:"),
        .contains("accessibilityLabel:"),
        .contains("Accessibility(identifier:"),
        .contains("Accessibility(label:"),
        .contains("NSAttributedString.Key("),
        .contains("Notification.Name("),
        .contains("Notification.Key("),
        .contains("DispatchQueue("),
        .containsAnd("identifier:", .previousLine(numEarlier: 1, .contains("Accessibility("))),
        .containsAnd("label:", .previousLine(numEarlier: 1, .contains("Accessibility("))),
        .containsAnd("label:", .previousLine(numEarlier: 2, .contains("Accessibility("))),
        .contains("SQL("),
        .regex(".*static var databaseTableName: String"),
        .regex("Logger\\..*\\("),
        .regex("OWSLogger\\..*\\("),
        .regex("case .* = "),
        .regex("Error.*\\(")
    ]
}

// Execute the desired actions
let targetActions: Set<ScriptAction> = {
    let args = CommandLine.arguments
    
    // The first argument is the file name
    guard args.count > 1 else { return [.lintStrings] }
    
    return Set(args.suffix(from: 1).map { (ScriptAction(rawValue: $0) ?? .lintStrings) })
}()

print("------------ Searching Through Files ------------")
let projectState: ProjectState = ProjectState(
    path: (
        ProcessInfo.processInfo.environment["PROJECT_DIR"] ??
        FileManager.default.currentDirectoryPath
    ),
    loadSourceFiles: targetActions.contains(.lintStrings)
)
print("------------ Processing \(projectState.localizationFiles.count) Localization File(s) ------------")
targetActions.forEach { $0.perform(projectState: projectState) }

// MARK: - ScriptAction

enum ScriptAction: String {
    case validateFilesCopied = "validate"
    case lintStrings = "lint"
    
    func perform(projectState: ProjectState) {
        // Perform the action
        switch self {
            case .validateFilesCopied:
                print("------------ Checking Copied Files ------------")
                guard
                    let builtProductsPath: String = ProcessInfo.processInfo.environment["BUILT_PRODUCTS_DIR"],
                    let productName: String = ProcessInfo.processInfo.environment["FULL_PRODUCT_NAME"],
                    let enumerator: FileManager.DirectoryEnumerator = FileManager.default.enumerator(
                        at: URL(fileURLWithPath: "\(builtProductsPath)/\(productName)"),
                        includingPropertiesForKeys: [.isDirectoryKey],
                        options: [.skipsHiddenFiles]
                    ),
                    let fileUrls: [URL] = enumerator.allObjects as? [URL]
                else { return Output.error("Could not retrieve list of files within built product") }
                
                let localizationFiles: Set<String> = Set(fileUrls
                    .filter { $0.path.hasSuffix(".lproj") }
                    .map { $0.lastPathComponent.replacingOccurrences(of: ".lproj", with: "") })
                let missingFiles: Set<String> = Set(projectState.localizationFiles
                    .map { $0.name })
                    .subtracting(localizationFiles)
                
                guard missingFiles.isEmpty else {
                    return Output.error("Translations missing from \(productName): \(missingFiles.joined(separator: ", "))")
                }
                break
                
            case .lintStrings:
                guard !projectState.localizationFiles.isEmpty else {
                    return print("------------ Nothing to lint ------------")
                }
                
                // Add warnings for any duplicate keys
                projectState.localizationFiles.forEach { file in
                    // Show errors for any duplicates
                    file.duplicates.forEach { phrase, original in Output.duplicate(phrase, original: original) }
                    
                    // Show warnings for any phrases missing from the file
                    let allKeys: Set<String> = Set(file.keyPhrase.keys)
                    let missingKeysFromOtherFiles: [String: [String]] = projectState.localizationFiles.reduce(into: [:]) { result, otherFile in
                        guard otherFile.path != file.path else { return }
                        
                        let missingKeys: Set<String> = Set(otherFile.keyPhrase.keys)
                            .subtracting(allKeys)
                        
                        missingKeys.forEach { missingKey in
                            result[missingKey] = ((result[missingKey] ?? []) + [otherFile.name])
                        }
                    }
                    
                    missingKeysFromOtherFiles.forEach { missingKey, namesOfFilesItWasFound in
                        Output.warning(file, "Phrase '\(missingKey)' is missing (found in: \(namesOfFilesItWasFound.joined(separator: ", ")))")
                    }
                }
                
                // Process the source code
                print("------------ Processing \(projectState.sourceFiles.count) Source File(s) ------------")
                let allKeys: Set<String> = Set(projectState.primaryLocalizationFile.keyPhrase.keys)
                
                projectState.sourceFiles.forEach { file in
                    // Add logs for unlocalised strings
                    file.unlocalizedPhrases.forEach { phrase in
                        Output.warning(phrase, "Found unlocalized string '\(phrase.key)'")
                    }
                    
                    // Add errors for missing localised strings
                    let missingKeys: Set<String> = Set(file.keyPhrase.keys).subtracting(allKeys)
                    missingKeys.forEach { key in
                        switch file.keyPhrase[key] {
                            case .some(let phrase): Output.error(phrase, "Localized phrase '\(key)' missing from strings files")
                            case .none: Output.error(file, "Localized phrase '\(key)' missing from strings files")
                        }
                    }
                }
                break
        }
        
        print("------------ Complete ------------")
    }
}

// MARK: - Functionality

enum Regex {
    /// Returns a list of strings that match regex pattern from content
    ///
    /// - Parameters:
    ///   - pattern: regex pattern
    ///   - content: content to match
    /// - Returns: list of results
    static func matches(_ pattern: String, content: String) -> [String] {
        guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
            fatalError("Regex not formatted correctly: \(pattern)")
        }
        
        let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: content.utf16.count))
        
        return matches.map {
            guard let range = Range($0.range(at: 0), in: content) else {
                fatalError("Incorrect range match")
            }
            
            return String(content[range])
        }
    }
}

// MARK: - Output

enum Output {
    static func error(_ error: String) {
        print("error: \(error)")
    }
    
    static func error(_ location: Locatable, _ error: String) {
        print("\(location.location): error: \(error)")
    }
    
    static func warning(_ location: Locatable, _ warning: String) {
        print("\(location.location): warning: \(warning)")
    }
    
    static func duplicate(
        _ duplicate: KeyedLocatable,
        original: KeyedLocatable
    ) {
        print("\(duplicate.location): error: duplicate key '\(original.key)'")
        
        // Looks like the `note:` doesn't work the same as when XCode does it unfortunately so we can't
        // currently include the reference to the original entry
        // print("\(original.location): note: previously found here")
    }
}

// MARK: - ProjectState

struct ProjectState {
    let primaryLocalizationFile: LocalizationStringsFile
    let localizationFiles: [LocalizationStringsFile]
    let sourceFiles: [SourceFile]
    
    init(path: String, loadSourceFiles: Bool) {
        guard
            let enumerator: FileManager.DirectoryEnumerator = FileManager.default.enumerator(
                at: URL(fileURLWithPath: path),
                includingPropertiesForKeys: [.isDirectoryKey],
                options: [.skipsHiddenFiles]
            ),
            let fileUrls: [URL] = enumerator.allObjects as? [URL]
        else { fatalError("Could not locate files in path directory: \(path)") }
        
        // Get a list of valid URLs
        let lowerCaseExcludedPaths: Set<String> = Set(ProjectState.excludedPaths.map { $0.lowercased() })
        let validFileUrls: [URL] = fileUrls.filter { fileUrl in
            ((try? fileUrl.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == false) &&
            !lowerCaseExcludedPaths.contains { fileUrl.path.lowercased().contains($0) }
        }
        
        // Localization files
        let targetFileSuffixes: Set<String> = Set(ProjectState.validLocalisationSuffixes.map { $0.lowercased() })
        self.localizationFiles = validFileUrls
            .filter { fileUrl in targetFileSuffixes.contains { fileUrl.path.lowercased().contains($0) } }
            .map { LocalizationStringsFile(path: $0.path) }
        
        guard let primaryLocalizationFile: LocalizationStringsFile = self.localizationFiles.first(where: { $0.name == ProjectState.primaryLocalisationFile }) else {
            fatalError("Could not locate primary localization file: \(ProjectState.primaryLocalisationFile)")
        }
        self.primaryLocalizationFile = primaryLocalizationFile
        
        guard loadSourceFiles else {
            self.sourceFiles = []
            return
        }
        
        // Source files
        let lowerCaseSourceSuffixes: Set<String> = Set(ProjectState.validSourceSuffixes.map { $0.lowercased() })
        self.sourceFiles = validFileUrls
            .filter { fileUrl in lowerCaseSourceSuffixes.contains(".\(fileUrl.pathExtension)") }
            .compactMap { SourceFile(path: $0.path) }
    }
}

protocol Locatable {
    var location: String { get }
}

protocol KeyedLocatable: Locatable {
    var key: String { get }
}

extension ProjectState {
    // MARK: - LocalizationStringsFile
    
    struct LocalizationStringsFile: Locatable {
        struct Phrase: KeyedLocatable {
            let key: String
            let value: String
            let filePath: String
            let lineNumber: Int
            
            var location: String { "\(filePath):\(lineNumber)" }
        }
        
        let name: String
        let path: String
        let keyPhrase: [String: Phrase]
        let duplicates: [(Phrase, original: Phrase)]
        
        var location: String { path }
        
        init(path: String) {
            let result = LocalizationStringsFile.parse(path)
            
            self.name = (path
                .replacingOccurrences(of: "/Localizable.strings", with: "")
                .replacingOccurrences(of: ".lproj", with: "")
                .components(separatedBy: "/")
                .last ?? "Unknown")
            self.path = path
            self.keyPhrase = result.keyPhrase
            self.duplicates = result.duplicates
        }
        
        static func parse(_ path: String) -> (keyPhrase: [String: Phrase], duplicates: [(Phrase, original: Phrase)]) {
            guard
                let data: Data = FileManager.default.contents(atPath: path),
                let content: String = String(data: data, encoding: .utf8)
            else { fatalError("Could not read from path: \(path)") }
            
            let lines: [String] = content.components(separatedBy: .newlines)
            var duplicates: [(Phrase, original: Phrase)] = []
            var keyPhrase: [String: Phrase] = [:]
            
            lines.enumerated().forEach { lineNumber, line in
                guard
                    let key: String = Regex.matches("\"([^\"]*?)\"(?= =)", content: line).first,
                    let value: String = Regex.matches("(?<== )\"(.*?)\"(?=;)", content: line).first
                else { return }
                
                // Remove the quotation marks around the key
                let trimmedKey: String = String(key
                    .prefix(upTo: key.index(before: key.endIndex))
                    .suffix(from: key.index(after: key.startIndex)))
                
                // Files are 1-indexed but arrays are 0-indexed so add 1 to the lineNumber
                let result: Phrase = Phrase(
                    key: trimmedKey,
                    value: value,
                    filePath: path,
                    lineNumber: (lineNumber + 1)
                )
                
                switch keyPhrase[trimmedKey] {
                    case .some(let original): duplicates.append((result, original))
                    case .none: keyPhrase[trimmedKey] = result
                }
            }
            
            return (keyPhrase, duplicates)
        }
    }
    
    // MARK: - SourceFile
    
    struct SourceFile: Locatable {
        struct Phrase: KeyedLocatable {
            let term: String
            let filePath: String
            let lineNumber: Int
            
            var key: String { term }
            var location: String { "\(filePath):\(lineNumber)" }
        }
        
        let path: String
        let keyPhrase: [String: Phrase]
        let unlocalizedKeyPhrase: [String: Phrase]
        let phrases: [Phrase]
        let unlocalizedPhrases: [Phrase]
        
        var location: String { path }
        
        init?(path: String) {
            guard let result = SourceFile.parse(path) else { return nil }
            
            self.path = path
            self.keyPhrase = result.keyPhrase
            self.unlocalizedKeyPhrase = result.unlocalizedKeyPhrase
            self.phrases = result.phrases
            self.unlocalizedPhrases = result.unlocalizedPhrases
        }
        
        static func parse(_ path: String) -> (keyPhrase: [String: Phrase], phrases: [Phrase], unlocalizedKeyPhrase: [String: Phrase], unlocalizedPhrases: [Phrase])? {
            guard
                let data: Data = FileManager.default.contents(atPath: path),
                let content: String = String(data: data, encoding: .utf8)
            else { fatalError("Could not read from path: \(path)") }
            
            // If the file has the lint supression before the first import then ignore the file
            let preImportContent: String = (content.components(separatedBy: "import").first ?? "")
            
            guard !preImportContent.contains(ProjectState.lintSuppression) else {
                print("Explicitly ignoring \(path)")
                return nil
            }
            
            // Otherwise continue and process the file
            let lines: [String] = content.components(separatedBy: .newlines)
            var keyPhrase: [String: Phrase] = [:]
            var unlocalizedKeyPhrase: [String: Phrase] = [:]
            var phrases: [Phrase] = []
            var unlocalizedPhrases: [Phrase] = []
            
            lines.enumerated().forEach { lineNumber, line in
                let trimmedLine: String = line.trimmingCharacters(in: .whitespacesAndNewlines)
                
                // Ignore the line if it doesn't contain a quotation character (optimisation), it's
                // been suppressed or it's explicitly excluded due to the rules at the top of the file
                guard
                    trimmedLine.contains("\"") &&
                    !ProjectState.excludedUnlocalisedStringLineMatching
                        .contains(where: { $0.matches(trimmedLine, lineNumber, lines) })
                else { return }
                
                // Split line based on commented out content and exclude the comment from the linting
                let commentMatches: [String] = Regex.matches(
                    "//[^\\\"]*(?:\\\"[^\\\"]*\\\"[^\\\"]*)*",
                    content: line
                )
                let targetLine: String = (commentMatches.isEmpty ? line :
                    line.components(separatedBy: commentMatches[0])[0]
                )
                
                // Use regex to find `NSLocalizedString("", "")`, `"".localised()` and any other `""`
                // values in the source code
                //
                // Note: It's more complex because we need to exclude escaped quotation marks from
                // strings and also want to ignore any strings that have been commented out, Swift
                // also doesn't support "lookbehind" in regex so we can use that approach
                var isUnlocalized: Bool = false
                var allMatches: Set<String> = Set(
                    Regex
                        .matches(
                            "NSLocalizedString\\(@{0,1}\\\"[^\\\"\\\\]*(?:\\\\.[^\\\"\\\\]*)*(?:\\\")",
                            content: targetLine
                        )
                        .map { match in
                            match
                                .removingPrefixIfPresent("NSLocalizedString(@\"")
                                .removingPrefixIfPresent("NSLocalizedString(\"")
                                .removingSuffixIfPresent("\")")
                                .removingSuffixIfPresent("\"")
                        }
                )
                
                // If we didn't get any matches for the standard `NSLocalizedString` then try our
                // custom extension `"".localized()`
                if allMatches.isEmpty {
                    allMatches = allMatches.union(Set(
                        Regex
                            .matches(
                                "\\\"[^\\\"\\\\]*(?:\\\\.[^\\\"\\\\]*)*\\\"\\.localized",
                                content: targetLine
                            )
                            .map { match in
                                match
                                    .removingPrefixIfPresent("\"")
                                    .removingSuffixIfPresent("\".localized")
                            }
                    ))
                }
                
                /// If we still don't have any matches then try to match any strings as unlocalized strings (handling
                /// nested `"Test\"string\" value"`, empty strings and strings only composed of quotes `"""""""`)
                ///
                /// **Note:** While it'd be nice to have the regex automatically exclude the quotes doing so makes it _far_ less
                /// efficient (approx. by a factor of 8 times) so we remove those ourselves)
                if allMatches.isEmpty {
                    // Find strings which are just not localised
                    let potentialUnlocalizedStrings: [String] = Regex
                        .matches("\\\"[^\\\"\\\\]*(?:\\\\.[^\\\"\\\\]*)*(?:\\\")", content: targetLine)
                        // Remove the leading and trailing quotation marks
                        .map { $0.removingPrefixIfPresent("\"").removingSuffixIfPresent("\"") }
                        // Remove any empty strings
                        .filter { !$0.isEmpty }
                        // Remove any string conversations (ie. `.map { "\($0)" }`
                        .filter { value in !value.hasPrefix("\\(") || !value.hasSuffix(")") }
                    
                    allMatches = allMatches.union(Set(potentialUnlocalizedStrings))
                    isUnlocalized = true
                }
                
                // Remove any excluded phrases from the matches
                allMatches = allMatches.subtracting(ProjectState.excludedPhrases.map { "\($0)" })
                
                allMatches.forEach { match in
                    // Files are 1-indexed but arrays are 0-indexed so add 1 to the lineNumber
                    let result: Phrase = Phrase(
                        term: match,
                        filePath: path,
                        lineNumber: (lineNumber + 1)
                    )
                    
                    if !isUnlocalized {
                        keyPhrase[match] = result
                        phrases.append(result)
                    }
                    else {
                        unlocalizedKeyPhrase[match] = result
                        unlocalizedPhrases.append(result)
                    }
                }
            }
            
            return (keyPhrase, phrases, unlocalizedKeyPhrase, unlocalizedPhrases)
        }
    }
}

indirect enum MatchType: Hashable {
    case prefix(String)
    case contains(String)
    case containsAnd(String, MatchType)
    case regex(String)
    case previousLine(numEarlier: Int, MatchType)
    
    func matches(_ value: String, _ index: Int, _ lines: [String]) -> Bool {
        switch self {
            case .prefix(let prefix):
                return value
                    .trimmingCharacters(in: .whitespacesAndNewlines)
                    .hasPrefix(prefix)
                
            case .contains(let other): return value.contains(other)
            case .containsAnd(let other, let otherMatch):
                guard value.contains(other) else { return false }
                
                return otherMatch.matches(value, index, lines)
                
            case .regex(let regex): return !Regex.matches(regex, content: value).isEmpty
                
            case .previousLine(let numEarlier, let type):
                guard index >= numEarlier else { return false }
                
                let targetIndex: Int = (index - numEarlier)
                return type.matches(lines[targetIndex], targetIndex, lines)
        }
    }
}

extension String {
    func removingPrefixIfPresent(_ value: String) -> String {
        guard hasPrefix(value) else { return self }
        
        return String(self.suffix(from: self.index(self.startIndex, offsetBy: value.count)))
    }
    
    func removingSuffixIfPresent(_ value: String) -> String {
        guard hasSuffix(value) else { return self }
        
        return String(self.prefix(upTo: self.index(self.endIndex, offsetBy: -value.count)))
    }
}
