import Foundation

final class GenerationResults: ObservableObject {

    /// The files that could not be accessed
    @Published
    var inaccessibleFiles: Set<FileResource> = []

    /// The files that could not be parsed, with the error message produced
    @Published
    var unparsableFiles: Set<FileResource> = []

    @Published
    var missingFiles: Set<String> = []

    @Published
    var missingTags: Set<String> = []

    @Published
    var missingPages: Set<String> = []

    @Published
    var externalLinks: Set<String> = []

    @Published
    var requiredFiles: Set<FileResource> = []

    @Published
    var externalFiles: Set<FileResource> = []

    @Published
    var imagesToGenerate: Set<ImageVersion> = []

    @Published
    var invalidCommands: Set<String> = []

    @Published
    var invalidBlocks: Set<String> = []

    @Published
    var warnings: Set<String> = []

    @Published
    var unsavedOutputFiles: Set<String> = []

    @Published
    var failedImages: Set<ImageVersion> = []

    @Published
    var emptyPages: Set<LocalizedPageId> = []

    /// The paths to the files in the output folder, without leading slashes
    @Published
    var outputFiles: Set<String> = []

    @Published
    var unusedFilesInOutput: Set<String> = []

    /// The paths to files required to be in the output folder, without leading slashes
    @Published
    var requiredOutputFiles: Set<String> = []

    /**
     The url redirects to install to prevent broken links.

     The key is the original url, and the value the new url of the content, relative to the output folder.
     The dictionary is used to create a map of redirects for Nginx.
     */
    @Published
    var redirects: [String : String] = [:]

    /// The cache of previously used GenerationResults
    private var cache: [LocalizedItemId : PageGenerationResults] = [:]

    private(set) var general: PageGenerationResults!

    @Published
    var resultCount: Int = 0

    // MARK: Life cycle

    init() {
        let id = LocalizedItemId(language: .english, itemType: .general)
        let general = PageGenerationResults(itemId: id, delegate: self)
        self.general = general
        cache[id] = general
        self.resultCount = 0
    }

    func makeResults(_ itemId: LocalizedItemId) -> PageGenerationResults {
        guard let result = cache[itemId] else {
            let result = PageGenerationResults(itemId: itemId, delegate: self)
            cache[itemId] = result
            update { self.resultCount += 1 }
            return result
        }
        return result
    }

    func makeResults(for type: ItemReference, in language: ContentLanguage) -> PageGenerationResults {
        let itemId = LocalizedItemId(language: language, itemType: type)
        return makeResults(itemId)
    }

    func makeResults(for page: Page, in language: ContentLanguage) -> PageGenerationResults {
        let itemId = LocalizedItemId(language: language, itemType: .page(page))
        return makeResults(itemId)
    }

    func makeResults(for tag: Tag, in language: ContentLanguage) -> PageGenerationResults {
        let itemId = LocalizedItemId(language: language, itemType: .tagPage(tag))
        return makeResults(itemId)
    }

    func reset() {
        update {
            self.inaccessibleFiles = []
            self.unparsableFiles = []
            self.missingFiles = []
            self.missingTags = []
            self.missingPages = []
            self.externalLinks = []
            self.requiredFiles = []
            self.externalFiles = []
            self.imagesToGenerate = []
            self.invalidCommands = []
            self.invalidBlocks = []
            self.warnings = []
            self.unsavedOutputFiles = []
            self.emptyPages = []
            self.redirects = [:]
            self.outputFiles = []
            self.unusedFilesInOutput = []
            self.requiredOutputFiles = []
        }
        for result in cache.values {
            result.reset()
        }
    }

    func recalculate() {
        let inaccessibleFiles = cache.values.map { $0.inaccessibleFiles }.union()
        update { self.inaccessibleFiles = inaccessibleFiles }
        let unparsableFiles = cache.values.map { $0.unparsableFiles.keys }.union()
        update { self.unparsableFiles = unparsableFiles }
        let missingFiles = cache.values.map { $0.missingFiles.keys }.union()
        update { self.missingFiles = missingFiles }
        let missingTags = cache.values.map { $0.missingLinkedTags.keys }.union()
        update { self.missingTags = missingTags }
        let missingPages = cache.values.map { $0.missingLinkedPages.keys }.union()
        update { self.missingPages = missingPages }
        let externalLinks = cache.values.map { $0.externalLinks }.union()
        update { self.externalLinks = externalLinks }
        let requiredFiles = cache.values.map { $0.requiredFiles }.union()
        update { self.requiredFiles = requiredFiles }
        let externalFiles = cache.values.map { $0.requiredFiles.filter { $0.isExternallyStored } }.union()
        update { self.externalFiles = externalFiles }
        let imagesToGenerate = cache.values.map { $0.imagesToGenerate }.union()
        update { self.imagesToGenerate = imagesToGenerate }
        let invalidCommands = cache.values.map { $0.invalidCommands.map { $0.markdown }}.union()
        update { self.invalidCommands = invalidCommands }
        let invalidBlocks = cache.values.map { $0.invalidBlocks.map { $0.markdown }}.union()
        update { self.invalidBlocks = invalidBlocks }
        let warnings = cache.values.map { $0.warnings }.union()
        update { self.warnings = warnings }
        let unsavedOutputFiles = cache.values.map { $0.unsavedOutputFiles.keys }.union()
        update { self.unsavedOutputFiles = unsavedOutputFiles }
        let emptyPages = cache.values.filter { $0.pageIsEmpty }.map { $0.itemId }.compactMap { id -> LocalizedPageId? in
            guard case .page(let page) = id.itemType else { return nil }
            return LocalizedPageId(language: id.language, pageId: page.identifier)
            }.asSet()
        update { self.emptyPages = emptyPages }
        let redirects = cache.values.compactMap { $0.redirect }.reduce(into: [:]) { $0[$1.originalUrl] = $1.newUrl }
        update { self.redirects = redirects }
    }

    private func update(_ operation: @escaping () -> Void) {
        DispatchQueue.main.async {
            operation()
        }
    }

    // MARK: Adding entries

    func inaccessibleContent(file: FileResource) {
        update { self.inaccessibleFiles.insert(file) }
    }

    func unparsable(file: FileResource) {
        update { self.unparsableFiles.insert(file) }
    }

    func missing(file: String) {
        update { self.missingFiles.insert(file) }
    }

    func missing(tag: String) {
        update { self.missingTags.insert(tag) }
    }

    func missing(page: String) {
        update { self.missingPages.insert(page) }
    }

    func externalLink(_ url: String) {
        update { self.externalLinks.insert(url) }
    }

    func require(file: FileResource) {
        update {
            self.requiredFiles.insert(file)
            if file.isExternallyStored {
                self.externalFiles.insert(file)
            }
        }
    }

    func require<S>(files: S) where S: Sequence, S.Element == FileResource {
        update {
            self.requiredFiles.formUnion(files)
            self.externalFiles.formUnion(files.filter { $0.isExternallyStored })
        }
    }

    func generate(_ image: ImageVersion) {
        update { self.imagesToGenerate.insert(image) }
    }

    func generate<S>(_ images: S) where S: Sequence, S.Element == ImageVersion {
        update { self.imagesToGenerate.formUnion(images) }
    }

    func invalidCommand(_ markdown: String) {
        update { self.invalidCommands.insert(markdown) }
    }

    func invalidBlock(_ markdown: String) {
        update { self.invalidBlocks.insert(markdown) }
    }

    func warning(_ warning: String) {
        update { self.warnings.insert(warning) }
    }

    func failed(image: ImageVersion) {
        update { self.failedImages.insert(image) }
    }

    func unsaved(_ path: String) {
        update { self.unsavedOutputFiles.insert(path) }
    }

    func empty(_ page: LocalizedPageId) {
        update { self.emptyPages.insert(page) }
    }

    func redirect(from originalUrl: String, to newUrl: String) {
        update { self.redirects[originalUrl] = newUrl }
    }

    func created(outputFile: String) {
        update { self.outputFiles.insert(outputFile.withLeadingSlashRemoved) }
    }

    func determineFiles(unusedIn existingFiles: Set<String>) {
        // All paths with leading without leading slashes
        let unused = existingFiles.subtracting(outputFiles).subtracting(requiredOutputFiles)
        update { self.unusedFilesInOutput = unused }
    }

    func determineMissingRequiredFiles(existingFiles: Set<String>) {
        // All paths with leading without leading slashes

        // Check the files required in the output against the existing files,
        // and flag missing ones
        let externalFilePaths = self.externalFiles.map { $0.absoluteUrl.withLeadingSlashRemoved }
        let fullFiles = existingFiles.union(externalFilePaths)
        let missing = requiredOutputFiles.filter { path in
            !fullFiles.contains(path) &&
            !fullFiles.contains(path + ".html") &&
            !fullFiles.contains(path + "/1.html")
        }
        update { self.requiredOutputFiles = missing }
    }

    func sources(forMissingPage page: String) -> [(page: LocalizedItemId, source: String)] {
        var all = [(page: LocalizedItemId, source: String)]()
        for (id, results) in cache {
            guard let sources = results.missingLinkedPages[page]?.sorted() else {
                continue
            }
            let additions = sources.map { (page: id, source: $0) }
            all.append(contentsOf: additions)
        }
        return all
    }

    func requiredOutputFile(_ path: String) {
        update { self.requiredOutputFiles.insert(path) }
    }
}

private extension Dictionary where Value == Set<LocalizedItemId> {

    mutating func remove<S>(keys: S, of item: LocalizedItemId) where S: Sequence, S.Element == Key {
        for key in keys {
            guard var value = self[key] else { continue }
            value.remove(item)
            if value.isEmpty {
                self[key] = nil
            } else {
                self[key] = value
            }
        }
    }
}
