import Foundation

public struct EditorDirtyLineInvalidationPlan: Hashable, Sendable {
    public var reason: EditorRenderReason
    public var isFullRender: Bool
    public var dirtyLineIndexes: [Int]
    public var changedRange: NSRange?

    public init(
        reason: EditorRenderReason,
        isFullRender: Bool,
        dirtyLineIndexes: [Int],
        changedRange: NSRange? = nil
    ) {
        self.reason = reason
        self.isFullRender = isFullRender
        self.dirtyLineIndexes = dirtyLineIndexes
        self.changedRange = changedRange
    }

    public var dirtyLineCount: Int {
        dirtyLineIndexes.count
    }

    public var requiresStyling: Bool {
        isFullRender || !dirtyLineIndexes.isEmpty
    }
}

public enum EditorDirtyLineInvalidator {
    public static func plan(
        previousText: String?,
        currentText: String,
        previousActiveLineIndex: Int?,
        currentActiveLineIndex: Int
    ) -> EditorDirtyLineInvalidationPlan {
        let currentLines = EditorActiveLineTracker.lines(
            from: currentText,
            activeLineIndex: currentActiveLineIndex
        )

        guard let previousText else {
            return EditorDirtyLineInvalidationPlan(
                reason: .initial,
                isFullRender: true,
                dirtyLineIndexes: currentLines.map(\.index)
            )
        }

        var dirtyLineIndexes = Set<Int>()
        let reason: EditorRenderReason
        var changedRange: NSRange?

        if previousText != currentText {
            reason = .sourceChange
            changedRange = changedUTF16Range(from: previousText, to: currentText)
            dirtyLineIndexes.formUnion(
                lineIndexesAffected(
                    by: changedRange ?? NSRange(location: 0, length: currentText.utf16.count),
                    in: currentText,
                    lineCount: currentLines.count
                )
            )
        } else if previousActiveLineIndex != currentActiveLineIndex {
            reason = .activeLineChange
        } else {
            reason = .viewUpdate
        }

        if let previousActiveLineIndex,
           previousActiveLineIndex != currentActiveLineIndex {
            dirtyLineIndexes.insert(previousActiveLineIndex)
            dirtyLineIndexes.insert(currentActiveLineIndex)
        }

        let validLineIndexes = dirtyLineIndexes
            .filter { (0..<currentLines.count).contains($0) }
            .sorted()

        return EditorDirtyLineInvalidationPlan(
            reason: reason,
            isFullRender: false,
            dirtyLineIndexes: validLineIndexes,
            changedRange: changedRange
        )
    }

    private static func changedUTF16Range(from oldText: String, to newText: String) -> NSRange {
        let old = oldText as NSString
        let new = newText as NSString
        let commonLength = min(old.length, new.length)

        var prefixLength = 0
        while prefixLength < commonLength,
              old.character(at: prefixLength) == new.character(at: prefixLength) {
            prefixLength += 1
        }

        var oldSuffixStart = old.length
        var newSuffixStart = new.length
        while oldSuffixStart > prefixLength,
              newSuffixStart > prefixLength,
              old.character(at: oldSuffixStart - 1) == new.character(at: newSuffixStart - 1) {
            oldSuffixStart -= 1
            newSuffixStart -= 1
        }

        return NSRange(location: prefixLength, length: max(0, newSuffixStart - prefixLength))
    }

    private static func lineIndexesAffected(
        by changedRange: NSRange,
        in source: String,
        lineCount: Int
    ) -> Set<Int> {
        guard lineCount > 0 else { return [] }

        let sourceLength = source.utf16.count
        let startLocation = max(0, min(changedRange.location, sourceLength))
        let endLocation: Int
        if changedRange.length == 0 {
            endLocation = startLocation
        } else {
            endLocation = max(startLocation, min(changedRange.upperBound - 1, sourceLength))
        }

        let startLine = EditorActiveLineTracker.lineIndex(containing: startLocation, in: source)
        let endLine = EditorActiveLineTracker.lineIndex(containing: endLocation, in: source)
        let lowerBound = max(0, min(startLine, endLine) - 1)
        let upperBound = min(lineCount - 1, max(startLine, endLine) + 1)

        return Set(lowerBound...upperBound)
    }
}

private extension NSRange {
    var upperBound: Int {
        location + length
    }
}
