//
//  Filterer.swift
//  Tusker
//
//  Created by Shadowfacts on 12/3/22.
//  Copyright © 2022 Shadowfacts. All rights reserved.
//

import Foundation
import Pachyderm
import Combine

/// An opaque object that serves as the cache for the filtered-ness of a particular status.
class FilterState: @unchecked Sendable {
    static var unknown: FilterState { FilterState(state: .unknown) }
    
    fileprivate var state: State
    
    var isWarning: Bool {
        switch state {
        case .known(.warn(_), _):
            return true
        default:
            return false
        }
    }
    
    private init(state: State) {
        self.state = state
    }
    
    fileprivate enum State {
        case unknown
        case known(Filterer.Result, generation: Int)
    }
}

@MainActor
class Filterer {
    let mastodonController: MastodonController
    let context: FilterV1.Context
    
    var filtersChanged: ((Bool) -> Void)?
    
    var htmlConverter = HTMLConverter()
    private var hasSetup = false
    private var matchers = [(NSRegularExpression, Result)]()
    private var cancellables = Set<AnyCancellable>()
    private var filterObservers = Set<AnyCancellable>()
    
    private var hideReblogsInTimelines: Bool
    private var hideRepliesInTimelines: Bool
    
    // the generation is incremented when the matchers change, to indicate that older cached FilterStates
    // are no longer valid, without needing to go through and update each of them
    private var generation = 0
    
    init(mastodonController: MastodonController, context: FilterV1.Context) {
        self.mastodonController = mastodonController
        self.context = context
        self.hideReblogsInTimelines = Preferences.shared.hideReblogsInTimelines
        self.hideRepliesInTimelines = Preferences.shared.hideRepliesInTimelines
        
        mastodonController.$filters
            .sink { [unowned self] in
                if self.hasSetup {
                    self.setupFilters(filters: $0)
                }
            }
            .store(in: &cancellables)
        
        if context == .home {
            Preferences.shared.$hideReblogsInTimelines
                .sink { [unowned self] newValue in
                    if newValue != hideReblogsInTimelines {
                        self.hideReblogsInTimelines = newValue
                        self.generation += 1
                        self.filtersChanged?(true)
                    }
                }
                .store(in: &cancellables)
            Preferences.shared.$hideRepliesInTimelines
                .sink { [unowned self] newValue in
                    if newValue != hideRepliesInTimelines {
                        self.hideRepliesInTimelines = newValue
                        self.generation += 1
                        self.filtersChanged?(true)
                    }
                }
                .store(in: &cancellables)
        }
    }
    
    private func setupFilters(filters: [FilterMO]) {
        let oldMatchers = matchers
        
        matchers = []
        filterObservers = []
        for filter in filters where (filter.expiresAt == nil || filter.expiresAt! > Date()) && filter.contexts.contains(context) {
            guard let matcher = filter.createMatcher() else {
                continue
            }
            matchers.append(matcher)
            
            filter.objectWillChange
                .sink { [unowned self] _ in
                    // wait until after the change happens
                    DispatchQueue.main.async {
                        self.setupFilters(filters: self.mastodonController.filters)
                    }
                }
                .store(in: &filterObservers)
        }
        
        if hasSetup {
            var allMatch: Bool = true
            var actionsChanged: Bool = false
            if matchers.count != oldMatchers.count {
                allMatch = false
                actionsChanged = true
            } else {
                for (old, new) in zip(oldMatchers, matchers) {
                    if old.1 != new.1 {
                        allMatch = false
                        actionsChanged = true
                        break
                    } else if old.0.pattern != new.0.pattern {
                        allMatch = false
                        if new.1 == .hide {
                            // if the pattern's changed and the action is hide, then the cell type for existing items may change
                            actionsChanged = true
                            break
                        } else {
                            // continue because we want to know if any actions changed
                            continue
                        }
                    }
                }
            }
            if !allMatch {
                generation += 1
                filtersChanged?(actionsChanged)
            }
        } else {
            hasSetup = true
        }
    }
    
    // Use a closure for the status in case the result is cached and we don't need to look it up
    func resolve(state: FilterState, status: () -> (StatusMO, Bool)) -> (Filterer.Result, NSAttributedString?) {
        switch state.state {
        case .known(_, generation: let knownGen) where knownGen < generation:
            fallthrough
        case .unknown:
            let (status, isReblog) = status()
            let (result, attributedString) = doResolve(status: status, isReblog: isReblog)
            state.state = .known(result, generation: generation)
            return (result, attributedString)
        case .known(let result, _):
            return (result, nil)
        }
    }
    
    func setResult(_ result: Result, for state: FilterState) {
        state.state = .known(result, generation: generation)
    }
    
    func isKnownHide(state: FilterState) -> Bool {
        switch state.state {
        case .known(.hide, generation: let gen) where gen >= generation:
            return true
        default:
            return false
        }
    }
    
    private func doResolve(status: StatusMO, isReblog: Bool) -> (Result, NSAttributedString?) {
        if !hasSetup {
            setupFilters(filters: mastodonController.filters)
        }
        if context == .home {
            if hideReblogsInTimelines,
               isReblog {
                return (.hide, nil)
            } else if hideRepliesInTimelines,
                      status.inReplyToID != nil {
                return (.hide, nil)
            }
        }
        if matchers.isEmpty {
            return (.allow, nil)
        }
        @Lazy var text = self.htmlConverter.convert(status.content)
        for (regex, result) in matchers {
            if (!status.spoilerText.isEmpty && regex.numberOfMatches(in: status.spoilerText, range: NSRange(location: 0, length: status.spoilerText.utf16.count)) > 0)
                || regex.numberOfMatches(in: text.string, range: NSRange(location: 0, length: text.length)) > 0 {
                return (result, _text.valueIfInitialized)
            }
        }
        return (.allow, _text.valueIfInitialized)
    }
    
    enum Result: Equatable {
        case allow
        case hide
        case warn(String)
    }
}

private extension FilterMO {
    func createMatcher() -> (NSRegularExpression,  Filterer.Result)? {
        guard keywords.count > 0 else {
            return nil
        }
        
        // TODO: it would be cool to use the Regex builder stuff for this, but it's iOS 16 only
        var pattern = ""
        
        var isFirst = true
        for keyword in keywordMOs {
            if isFirst {
                isFirst = false
            } else {
                pattern += "|"
            }
            pattern += "("
            if keyword.wholeWord {
                pattern += "\\b"
            }
            pattern += NSRegularExpression.escapedPattern(for: keyword.keyword)
            if keyword.wholeWord {
                pattern += "\\b"
            }
            pattern += ")"
        }
        
        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.useUnicodeWordBoundaries, .caseInsensitive]) else {
            return nil
        }
        
        let result: Filterer.Result
        switch filterAction {
        case .hide:
            result = .hide
        case .warn:
            result = .warn(titleOrKeyword)
        }
        
        return (regex, result)
    }
}
