import APIClient
import CoreMedia
import Foundation

// swiftlint:disable file_length

/*
    LyricsAppearance is an abstract data model to describe which
    sets of lyrics to display at which timestamp of a song.

    It can be used to simplify view logic at the point of rendering the view,
    by creating sets of at most 5 lyrics at a time marking which lyric is active
    and which lyrics came before the "primary" lyric in order and which are coming
    up next.

    in the Suno app it is being used to render a rolling view of
    exapanded (5 line) or mini (2 line) lyrics.
 */

public enum LyricsAppearance {
    public static let timeKeyInterval: TimeInterval = 0.5
    public static func timeToLyricsTimeKey(_ time: CMTime) -> TimeInterval {
        let seconds = CMTimeGetSeconds(time)
        return round(seconds / LyricsAppearance.timeKeyInterval) * LyricsAppearance.timeKeyInterval
    }

    public static func secondsToLyricsTimeKey(_ seconds: Double) -> TimeInterval {
        return round(seconds / LyricsAppearance.timeKeyInterval) * LyricsAppearance.timeKeyInterval
    }

    public struct TimedLyric: Equatable {
        public let sectionMarker: String?
        public let index: Int
        public let line: String
        public let startsAt: TimeInterval?
    }

    public struct CacheMap: Equatable {
        public static let empty: CacheMap = .init(valueMap: [:])

        public enum AppearancePosition {
            case offScreenAbove
            case previousDistanceTwo
            case previousDistanceOne
            case primary
            case nextDistanceOne
            case nextDistanceTwo
            case offScreenBelow
        }

        public enum PointOfInterest {
            case valid(timeMarker: TimeInterval, internalIndex: Int)
            case invalid
        }

        /*
            [Intended Use Visual Diagram]

            case .offScreenAbove

            |Top of Lyrics View =======================|

            -----           case .previousDistanceTwo

            ----------      case .previousDistanceOne

            -------------   case .primary

            ----------      case .nextDistanceOne

            -----           case .nextDistanceTwo

            |Bottom of Lyrics View  ===================|

            case .offScreenBelow

            **
            The above dashes represents chunks of lyrics.
            the chunks of lyrics could be one line or multiple

            Usually the backend splitting logic makes the lines
            around 1-3 lines, however some edge cases exist that
            are handled by the client splitting logic.

            It is unlikely that any lyric chunk should be
            more than 5 lines tall
            **

         */

        // Time Keys are half second keys 0.0, 0.5, 1.0, 1.5 ....
        public let valueMap: [TimeInterval: CacheSet]
        public let maxTimeKey: TimeInterval

        // Points of interest are points at which a different lyric would be visible
        private let pointsOfInterest: [TimeInterval]

        public var isEmptyByContent: Bool {
            return valueMap.isEmpty
        }

        public func appearanceSetStyle(_ timeKey: Double) -> CacheSet.PrimaryIndexType? {
            let clampedTimeKey = min(maxTimeKey, timeKey)
            return valueMap[clampedTimeKey]?.primaryIndexStyle
        }

        public func currentPointOfInterest(_ timeKey: Double) -> PointOfInterest {
            let clampedTimeKey = min(maxTimeKey, timeKey)
            let timeKeyToCheck = LyricsAppearance.secondsToLyricsTimeKey(clampedTimeKey)

            guard !pointsOfInterest.isEmpty else { return .invalid }
            guard let index = pointsOfInterest.firstIndex(where: { $0 >= timeKeyToCheck }) else {
                let lastIndex = pointsOfInterest.count - 1
                return .valid(timeMarker: pointsOfInterest[lastIndex], internalIndex: lastIndex)
            }
            return .valid(timeMarker: pointsOfInterest[index], internalIndex: index)
        }

        public func pointOfInterestForCachedInternalIndex(_ index: Int) -> PointOfInterest {
            guard !pointsOfInterest.isEmpty else { return .invalid }
            let indexOffset = min(max(index, .zero), pointsOfInterest.count - 1)
            guard pointsOfInterest.indices.contains(indexOffset) else { return .invalid }
            return .valid(timeMarker: pointsOfInterest[indexOffset], internalIndex: indexOffset)
        }

        public func appearancePosition(_ timeKey: Double, lyricIndex: Int) -> AppearancePosition {
            let clampedTimeKey = min(timeKey, maxTimeKey)
            let previousLyricIndices = previousLyricIndicesAtTime(clampedTimeKey)
            let primaryLyricIndex = primaryLyricIndexAtTime(clampedTimeKey)
            let nextLyricIndices = nextLyricIndicesAtTime(clampedTimeKey)

            if let primaryLyricIndex, primaryLyricIndex == lyricIndex {
                return .primary

            } else if previousLyricIndices.contains(where: { $0 == lyricIndex }) {
                // previousLyricIndices is at most 2 long
                if previousLyricIndices.count == 2 {
                    let distanceTwoIndex = previousLyricIndices[0]
                    return distanceTwoIndex == lyricIndex ? .previousDistanceTwo : .previousDistanceOne
                } else {
                    return .previousDistanceOne
                }

            } else if nextLyricIndices.contains(where: { $0 == lyricIndex }) {
                // nextLyricIndices is at most 2 long
                if nextLyricIndices.count == 2 {
                    let distanceTwoIndex = nextLyricIndices[1]
                    return distanceTwoIndex == lyricIndex ? .nextDistanceTwo : .nextDistanceOne
                } else {
                    return .nextDistanceOne
                }

            } else if let primaryLyricIndex, lyricIndex < primaryLyricIndex {
                return .offScreenAbove

            } else {
                return .offScreenBelow
            }
        }

        public func onScreenLineIndices(_ timeKey: Double) -> [Int] {
            let clampedTimeKey = min(timeKey, maxTimeKey)
            guard let value = valueMap[clampedTimeKey] else { return [] }
            return value.onScreenLineIndices
        }

        public func primaryLyricIndexAtTime(_ timeKey: Double) -> Int? {
            let clampedTimeKey = min(timeKey, maxTimeKey)
            guard let value = valueMap[clampedTimeKey] else { return nil }
            return value.primaryIndex
        }

        public func previousLyricIndicesAtTime(_ timeKey: Double) -> [Int] {
            let clampedTimeKey = min(timeKey, maxTimeKey)
            guard let value = valueMap[clampedTimeKey] else { return [] }
            return value.previousLineIndices
        }

        public func nextLyricIndicesAtTime(_ timeKey: Double) -> [Int] {
            let clampedTimeKey = min(timeKey, maxTimeKey)
            guard let value = valueMap[clampedTimeKey] else { return [] }
            return value.nextLineIndices
        }

        init(valueMap: [Double: CacheSet],
             maxTimeKey: Double = .zero,
             pointsOfInterest: [TimeInterval] = [])
        {
            self.valueMap = valueMap
            self.maxTimeKey = maxTimeKey
            self.pointsOfInterest = pointsOfInterest
        }
    }

    public struct CacheSet: Equatable {
        static let empty: CacheSet = .init(
            primaryIndexStyle: .beforeStart,
            primaryInternalIndex: .zero,
            onScreenLineIndices: []
        )

        public enum PrimaryIndexType {
            // The song has not started playing the first lyric
            // This format will currently have the same lyrics as the first standardPrimary
            case beforeStart

            // There is a lyric in the main spot
            case standardPrimary
        }

        public let primaryIndexStyle: PrimaryIndexType
        public let primaryInternalIndex: Int
        public let onScreenLineIndices: [Int]

        var primaryIndex: Int? {
            return onScreenLineIndices.enumerated().first(where: { $0.offset == primaryInternalIndex })?.element
        }

        var previousLineIndices: [Int] {
            switch primaryIndexStyle {
            case .beforeStart:
                return []
            case .standardPrimary:
                return onScreenLineIndices.enumerated().filter { $0.offset < primaryInternalIndex }.map { $0.element }
            }
        }

        var nextLineIndices: [Int] {
            return onScreenLineIndices.enumerated().filter { $0.offset > primaryInternalIndex }.map { $0.element }
        }

        init(primaryIndexStyle: PrimaryIndexType, primaryInternalIndex: Int, onScreenLineIndices: [Int]) {
            self.primaryIndexStyle = primaryIndexStyle
            self.primaryInternalIndex = primaryInternalIndex
            self.onScreenLineIndices = onScreenLineIndices
        }
    }

    static func createCacheAppearanceMap(_ totalTime: CMTime?, overallOffset _: TimeInterval = .zero, lyrics: [TimedLyric]) -> CacheMap {
        return createCacheAppearanceMap(totalTime?.roundedSingleDecimalSeconds, lyrics: lyrics)
    }

    /*
        Create Cache Map is used to create a lookup table inside of Lyrics Appearance
        it creates a table of timemarker keyed lyric sets to be later rendered in a view or other rendering context
        Total time is used in order to determine which time markers to create

        OverallOffset is a way to add delay to the entire sequence of lyrics as a whole so every lyric would show up
        x many seconds later than was originally intended.

        Lyrics is the the data source to index off of

        Integration notes:
        - Currently only streaming lyrics use overall offset as they do not account for intro time
        - The general (or completed) use case of LyricsAppearance would assume that lyrics all come in at their given times
          and overall offset would be zero
     */
    // swiftlint:disable:next cyclomatic_complexity
    static func createCacheAppearanceMap(_ totalTime: TimeInterval?, overallOffset: TimeInterval = .zero, lyrics: [TimedLyric]) -> CacheMap {
        guard let totalTimeInterval = totalTime else { return .empty }
        var newCachedAppearanceMap: [Double: LyricsAppearance.CacheSet] = [:]

        guard let firstLyricTime = lyrics.first?.startsAt else { return .init(valueMap: [:]) }

        let stopIndex = lyrics.count
        var maxTimeKey: TimeInterval = .zero
        var lastSet: CacheSet = .empty
        var pointsOfInterest: [TimeInterval] = []

        for timeMarker in stride(from: .zero, through: totalTimeInterval, by: LyricsAppearance.timeKeyInterval) {
            let newAppearanceSet: LyricsAppearance.CacheSet
            if timeMarker < firstLyricTime {
                // This is before the first primary lyric is on screen
                var onScreenAfter: [Int] = []

                if lyrics.indices.contains(0) {
                    onScreenAfter.append(0)
                }

                if lyrics.indices.contains(1) {
                    onScreenAfter.append(1)
                }

                if lyrics.indices.contains(2) {
                    onScreenAfter.append(2)
                }

                newAppearanceSet = .init(
                    primaryIndexStyle: .beforeStart,
                    primaryInternalIndex: .zero,
                    onScreenLineIndices: onScreenAfter
                )

                newCachedAppearanceMap[timeMarker] = newAppearanceSet

            } else if let firstLyricAtTime = lyrics.first(where: {
                guard let startTime = $0.startsAt else { return false }
                return (startTime + overallOffset) >= timeMarker
            }) {
                // This is the standard non-edge case area
                var onScreen: [Int] = []

                let primaryIndex = firstLyricAtTime.index - 1
                var primaryInternalIndex: Int = .zero

                if lyrics.indices.contains(primaryIndex - 2) {
                    onScreen.append(primaryIndex - 2)
                    primaryInternalIndex = 2
                }

                if lyrics.indices.contains(primaryIndex - 1) {
                    onScreen.append(primaryIndex - 1)
                    primaryInternalIndex = max(primaryInternalIndex, 1)
                }

                onScreen.append(primaryIndex)
                let nextIndex = primaryIndex + 1
                let nextNextIndex = nextIndex + 1

                if lyrics.indices.contains(nextIndex), nextIndex != stopIndex {
                    onScreen.append(nextIndex)
                }

                if lyrics.indices.contains(nextNextIndex), nextNextIndex != stopIndex {
                    onScreen.append(nextNextIndex)
                }

                newAppearanceSet = .init(
                    primaryIndexStyle: .standardPrimary,
                    primaryInternalIndex: primaryInternalIndex,
                    onScreenLineIndices: onScreen
                )

                newCachedAppearanceMap[timeMarker] = newAppearanceSet
            } else {
                // This is when we have reached the last lyric
                var onScreen: [Int] = []

                let primaryIndex = stopIndex - 1
                var primaryInternalIndex: Int = .zero

                if lyrics.indices.contains(primaryIndex - 2) {
                    onScreen.append(primaryIndex - 2)
                    primaryInternalIndex = 2
                }

                if lyrics.indices.contains(primaryIndex - 1) {
                    onScreen.append(primaryIndex - 1)
                    primaryInternalIndex = max(primaryInternalIndex, 1)
                }

                onScreen.append(primaryIndex)

                newAppearanceSet = .init(
                    primaryIndexStyle: .standardPrimary,
                    primaryInternalIndex: primaryInternalIndex,
                    onScreenLineIndices: onScreen
                )

                newCachedAppearanceMap[timeMarker] = newAppearanceSet
            }

            maxTimeKey = timeMarker

            if newAppearanceSet != lastSet {
                lastSet = newAppearanceSet
                pointsOfInterest.append(timeMarker)
            }
        }

        return .init(valueMap: newCachedAppearanceMap, maxTimeKey: maxTimeKey, pointsOfInterest: pointsOfInterest)
    }
}

public extension Clip {
    func promptToTimedLyrics() -> [LyricsAppearance.TimedLyric] {
        prompt
            .replacingOccurrences(of: "[", with: "**")
            .replacingOccurrences(of: "]", with: "**")
            .split(whereSeparator: \.isNewline)
            .enumerated()
            .map { .init(sectionMarker: nil, index: $0.offset, line: String($0.element), startsAt: nil) }
    }
}

extension Array where Element == AlignedLyric {
    func groupedByNewLine() -> [[AlignedLyric]] {
        reduce(into: [[AlignedLyric]]()) { result, lyric in

            if lyric.word.count > 32 && lyric.word.contains(.whitespace) {
                /*
                     Sometimes words show up as huge lines
                     This is roughly approximated because otherwise
                     We will get blocks that don't fit into the lyrics view design
                     and for now view we should process them into being a fit format

                     This roughly approximates the sync for this "word" only
                     all subsequent aligned lyrics will remain formatted and
                     synced as they are.

                     Guiding Principal:
                     It is better to be shown sections that fit nicely into the design
                     than overlapping chunky sections that make the user interface a mess

                     TODO:
                     We can eventually process this on backend and get
                     already nicely formatted lyrics
                 */

                let startsAt = lyric.startsAt
                let endsAt = lyric.endsAt
                let totalDuration = endsAt - startsAt

                let wordsSplitByWhiteSpace = lyric.word.split(separator: .whitespace)
                let wordCount = Double(wordsSplitByWhiteSpace.count)
                let newApproximatelyAlignedLyrics: [AlignedLyric] = wordsSplitByWhiteSpace.enumerated().compactMap { offset, word in
                    guard !word.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
                    let approxStart = ((Double(offset) / wordCount) * totalDuration) + startsAt
                    let approxEnd = ((Double(offset) / wordCount) * totalDuration) + startsAt
                    return .init(word: String("\(word) "), startsAt: approxStart, endsAt: approxEnd)
                }
                result.append(contentsOf: [newApproximatelyAlignedLyrics])

            } else {
                if result.isEmpty {
                    result.append([lyric])
                } else {
                    result[result.count - 1].append(lyric)
                }

                let shouldBreak = lyric.word.hasSuffix("\n")
                if shouldBreak {
                    result.append([])
                }
            }
        }
        .filter { !$0.isEmpty }
    }
}

extension String {
    var hasComma: Bool {
        return self.contains(",")
    }

    var hasQuestionMark: Bool {
        return self.contains("?")
    }

    var hasBothParenthesis: Bool {
        self.contains("(") && self.contains(")")
    }

    var hasStartingParenthesisOnly: Bool {
        return self.contains("(") && !self.contains(")")
    }

    var hasEndingParenthesisOnly: Bool {
        return !self.contains("(") && self.contains(")")
    }

    func removingParenthesis() -> String {
        var newString = self
        newString = newString.replacingOccurrences(of: ")", with: " ")
        newString = newString.replacingOccurrences(of: "(", with: "")
        return newString
    }

    func splitAtParenthesis() -> (start: String, end: String) {
        let stringSections = self.split(separator: ")")
        if stringSections.count == 1 {
            return (String(stringSections[0]), "")
        } else if stringSections.count >= 2 {
            return (String(stringSections[0]), String(stringSections[1]))
        } else {
            return ("", "")
        }
    }
}

extension Array where Element == [AlignedLyric] {
    func toTimedLyrics() -> [LyricsAppearance.TimedLyric] {
        reduce(into: []) { result, lyrics in

            var currentLyricLine: [AlignedLyric] = []
            var currentSectionMarker: String?
            var previousWordHasStartingParenthesis: Bool = false

            // Intentional inner function to access variables above repeatedly
            func addLyrics() {
                if let start = currentLyricLine.first?.startsAt {
                    let index = (result.last?.index ?? -1) + 1

                    let line = currentLyricLine.map(\.word).joined()
                        .replacingOccurrences(of: "\n", with: "")
                        .removingSongSectionKeyword()

                    result.append(.init(sectionMarker: currentSectionMarker, index: index, line: line, startsAt: start))
                }

                currentSectionMarker = nil
                currentLyricLine = []
            }

            for lyric in lyrics {
                let lyricWord = lyric.word
                let lyricStartsAt = lyric.startsAt

                if lyricWord.hasBothParenthesis {
                    addLyrics()
                    let splitWord = lyricWord.splitAtParenthesis()
                    let newMarker = splitWord.start.removingParenthesis()
                    currentSectionMarker = newMarker

                    if !splitWord.end.isEmpty {
                        currentLyricLine.append(.init(
                            word: splitWord.end,
                            startsAt: lyricStartsAt,
                            endsAt: lyric.endsAt
                        )
                        )
                    }

                } else if previousWordHasStartingParenthesis {
                    // Completing lyrical notes process
                    // from next if block
                    previousWordHasStartingParenthesis = false
                    let stringParts = lyricWord.splitAtParenthesis()

                    if !stringParts.start.isEmpty {
                        currentSectionMarker = stringParts.start
                    }

                    if !stringParts.end.isEmpty {
                        currentLyricLine.append(.init(
                            word: stringParts.end,
                            startsAt: lyricStartsAt,
                            endsAt: lyric.endsAt
                        )
                        )
                    }

                } else if lyricWord.hasStartingParenthesisOnly {
                    // Reached a word that should be added as
                    // lyrical notes rather than lyrics
                    previousWordHasStartingParenthesis = true
                    currentLyricLine.append(.init(
                        word: lyricWord.removingParenthesis(),
                        startsAt: lyricStartsAt,
                        endsAt: lyric.endsAt
                    ))
                    addLyrics()
                } else {
                    currentLyricLine.append(lyric)
                }

                if currentLyricLine.count > 10, lyricWord.hasComma || lyricWord.hasQuestionMark {
                    // Reach a nice point to break up a long line
                    addLyrics()
                } else if currentLyricLine.count > 16 {
                    // Reach a point where it is probably ok to break a line
                    addLyrics()
                }
            }

            addLyrics()
        }
    }
}

public extension CMTime {
    var roundedSingleDecimalSeconds: TimeInterval {
        let seconds = Double(CMTimeGetSeconds(self) * 10.0)
        return floor(seconds) / 10.0
    }
}

extension String {
    func removingSongSectionKeyword() -> String {
        let pattern = "\\[[^\\]]*\\]"

        do {
            let regex = try NSRegularExpression(pattern: pattern)
            let range = NSRange(location: 0, length: self.utf16.count)
            let modifiedString = regex.stringByReplacingMatches(in: self, range: range, withTemplate: "")
            return modifiedString
        } catch {
            print("Invalid regex: \(error.localizedDescription)")
            return self
        }
    }
}
