//
//  NoteContent.swift
//  damus
//
//  Created by Daniel D’Aquino on 2023-11-24.
//

import Foundation
import MarkdownUI
import UIKit

struct NoteArtifactsSeparated: Equatable {
    static func == (lhs: NoteArtifactsSeparated, rhs: NoteArtifactsSeparated) -> Bool {
        return lhs.content == rhs.content
    }
    
    let content: CompatibleText
    let words: Int
    let urls: [UrlType]
    let invoices: [Invoice]
    
    var media: [MediaUrl] {
        return urls.compactMap { url in url.is_media }
    }
    
    var images: [URL] {
        return urls.compactMap { url in url.is_img }
    }
    
    var links: [URL] {
        return urls.compactMap { url in url.is_link }
    }
    
    static func just_content(_ content: String) -> NoteArtifactsSeparated {
        let txt = CompatibleText(attributed: AttributedString(stringLiteral: content))
        return NoteArtifactsSeparated(content: txt, words: 0, urls: [], invoices: [])
    }
}

enum NoteArtifactState {
    case not_loaded
    case loading
    case loaded(NoteArtifacts)
    
    var artifacts: NoteArtifacts? {
        if case .loaded(let artifacts) = self {
            return artifacts
        }
        
        return nil
    }
    
    var should_preload: Bool {
        switch self {
        case .loaded:
            return false
        case .loading:
            return false
        case .not_loaded:
            return true
        }
    }
}

func note_artifact_is_separated(kind: NostrKind?) -> Bool {
    return kind != .longform
}

func render_immediately_available_note_content(ndb: Ndb, ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> NoteArtifacts {
    if ev.known_kind == .longform {
        return .longform(LongformContent(ev.content))
    }
    
    do {
        return try NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blocks in
            return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
        })
    }
    catch {
        // TODO: Improve error handling in the future, bubbling it up so that the view can decide how display errors. Keep legacy behavior for now.
        return .separated(.just_content(ev.get_content(keypair)))
    }
}

actor ContentRenderer {
    func render_note_content(ndb: Ndb, ev: NostrEvent, profiles: Profiles, keypair: Keypair) async -> NoteArtifacts {
        return render_immediately_available_note_content(ndb: ndb, ev: ev, profiles: profiles, keypair: keypair)
    }
}

// FIXME(tyiu): There are a lot of hacks to get this function to render the blocks correctly.
// However, the entire note content rendering logic just needs to be rewritten.
// Block previews should actually be rendered in the position of the note content where it was found.
// Currently, we put some previews at the bottom of the note, which is incorrect as they take things out of
// the author's intended context.
func render_blocks(blocks: borrowing NdbBlockGroup, profiles: Profiles, can_hide_last_previewable_refs: Bool = false) -> NoteArtifactsSeparated {
    var invoices: [Invoice] = []
    var urls: [UrlType] = []
    
    var end_mention_count = 0
    var end_url_count = 0
    
    let note_ref_count: Int? = try? blocks.reduce(initialResult: 0) { index, partialResult, item in
        switch item {
        case .mention(let mention):
            if let typ = mention.bech32_type,
               typ.is_notelike {
                return .loopReturn(partialResult + 1)
            }
        default:
            break
        }
        return .loopContinue
    }
    let one_note_ref = note_ref_count == 1

    // Search backwards until we find the beginning index of the chain of previewables that reach the end of the content.
    var hide_text_index: Int = 0
    if can_hide_last_previewable_refs {
        let _: ()? = blocks.withList({ blocksList in
            let endIndex = blocksList.count
            hide_text_index = endIndex
            return blocksList.forEachItemReversed({ index, block in
                if block.is_previewable {
                    switch block {
                    case .mention:
                        end_mention_count += 1
                        
                        // If there is more than one previewable mention,
                        // do not hide anything because we allow rich rendering of only one mention currently.
                        // This should be fixed in the future to show events inline instead.
                        if end_mention_count > 1 {
                            hide_text_index = endIndex
                            return .loopBreak
                        }
                    case .url(let url_block):
                        guard let url = URL(string: url_block.as_str()) else {
                            return .loopContinue    // We can't classify this, ignore and move on
                        }
                        let url_type = classify_url(url)
                        if case .link = url_type {
                            end_url_count += 1
                            
                            // If there is more than one link, do not hide anything because we allow rich rendering of only
                            // one link.
                            if end_url_count > 1 {
                                hide_text_index = endIndex
                                return .loopBreak
                            }
                        }
                    default:
                        break
                    }
                    hide_text_index = index
                }
                else {
                    switch block {
                    case .text(let txt_block):
                        if let txt = NdbBlock.convertToStringCopy(from: txt_block),
                           txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                            // We should hide whitespace at the end sequence.
                            hide_text_index = index
                        }
                        else {
                            return .loopBreak
                        }
                    case .hashtag(_):
                        // SPECIAL CASE:
                        // We should keep hashtags at the end sequence but hide all the other previewables around it.
                        hide_text_index = index
                    default:
                        return .loopBreak
                    }
                }
                return .loopContinue
            })
        })
    }

    let txt: CompatibleText? = try? blocks.withList({ blocksList in
        let endIndex = blocksList.count
        return try blocksList.reduce(initialResult: CompatibleText(), { index, str, block in

            // Add the rendered previewable blocks to their type-specific lists.
            switch block {
            case .url(let url_block):
                guard let url_string = NdbBlock.convertToStringCopy(from: url_block),
                      let url = URL(string: url_string) else {
                    break    // We can't classify this, ignore and move on
                }
                let url_type = classify_url(url)
                urls.append(url_type)
            case .invoice(let invoice_block):
                invoices.append(invoice_block.as_invoice())
            default:
                break
            }

            if can_hide_last_previewable_refs {
                // If there are previewable blocks that occur before the consecutive sequence of them at the end of the content,
                // we should not hide the text representation of any previewable block to avoid altering the format of the note.
                if index < hide_text_index && block.is_previewable {
                    hide_text_index = endIndex
                }

                // No need to show the text representation of the block if the only previewables are the sequence of them
                // found at the end of the content.
                // This is to save unnecessary use of screen space.
                // The only exception is that if there are hashtags embedded in the end sequence, which is not uncommon,
                // then we still want to show those hashtags but hide everything else that is previewable in the end sequence.
                if index >= hide_text_index {
                    switch block {
                    case .text(let txt_block):
                        if let txt = NdbBlock.convertToStringCopy(from: txt_block),
                           txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                            let returnItem: CompatibleText? = blocksList.useItem(at: index + 1, { matchingBlock in
                                switch matchingBlock {
                                case .hashtag(_):
                                    return str + CompatibleText(stringLiteral: reduce_text_block(ind: index, hide_text_index: hide_text_index, txt: txt))
                                default:
                                    return nil
                                }
                            }) ?? nil
                            if let returnItem {
                                return .loopReturn(returnItem)
                            }
                        }
                    case .hashtag(let htag):
                        return .loopReturn(str + hashtag_str(htag.as_str()))
                    default:
                        return .loopContinue
                    }
                    return .loopReturn(str)
                }
            }

            switch block {
            case .mention(let m):
                if let typ = m.bech32_type, typ.is_notelike, one_note_ref {
                    return .loopContinue
                }
                guard let mention = MentionRef(block: m) else { return .loopContinue }
                return .loopReturn(str + mention_str(.any(mention), profiles: profiles))
            case .text(let txt):
                var hide_text_index_argument = hide_text_index
                blocksList.useItem(at: index+1, { block in
                    switch block {
                    case .hashtag(_):
                        // SPECIAL CASE:
                        // Do not trim whitespaces from suffix if the following block is a hashtag.
                        // This is because of the code further up (see "SPECIAL CASE").
                        hide_text_index_argument = -1
                    default:
                        break
                    }
                })
                return .loopReturn(str + CompatibleText(stringLiteral: reduce_text_block(ind: index, hide_text_index: hide_text_index_argument, txt: txt.as_str())))
            case .hashtag(let htag):
                return .loopReturn(str + hashtag_str(htag.as_str()))
            case .invoice:
                // Invoice already added in previewable-collection switch above
                break
            case .url(let url):
                guard let url = URL(string: url.as_str()) else { return .loopContinue }
                return .loopReturn(str + url_str(url))
            case .mention_index:
                return .loopContinue
            }
            return .loopContinue
        })
    })

    return NoteArtifactsSeparated(content: txt ?? CompatibleText(), words: blocks.words, urls: urls, invoices: invoices)
}

func reduce_text_block(ind: Int, hide_text_index: Int, txt: String) -> String {
    var trimmed = txt

    // Trim leading whitespaces.
    if ind == 0 {
        trimmed = trim_prefix(trimmed)
    }

    // Trim trailing whitespaces if the following blocks will be hidden or if this is the last block.
    if ind == hide_text_index - 1 {
        trimmed = trim_suffix(trimmed)
    }

    return trimmed
}

func invoice_str(_ invoice: Invoice) -> CompatibleText {
    var attributedString = AttributedString(stringLiteral: abbrev_identifier(invoice.string))
    attributedString.link = URL(string: "damus:lightning:\(invoice.string)")
    attributedString.foregroundColor = DamusColors.purple

    return CompatibleText(attributed: attributedString)
}

func url_str(_ url: URL) -> CompatibleText {
    var attributedString = AttributedString(stringLiteral: url.absoluteString)
    attributedString.link = url
    attributedString.foregroundColor = DamusColors.purple
    
    return CompatibleText(attributed: attributedString)
}

func classify_url(_ url: URL) -> UrlType {
    let fileExtension = url.lastPathComponent.lowercased().components(separatedBy: ".").last

    switch fileExtension {
    case "png", "jpg", "jpeg", "gif", "webp":
        return .media(.image(url))
    case "mp4", "mov", "m3u8":
        return .media(.video(url))
    default:
        return .link(url)
    }
}

func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
    let attachment = NSTextAttachment()
    attachment.image = img
    let attachmentString = NSAttributedString(attachment: attachment)
    let wrapped = AttributedString(attachmentString)
    astr.append(wrapped)
}

func getDisplayName(pk: Pubkey, profiles: Profiles) -> String {
    let profile = try? profiles.lookup(id: pk)
    return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
}

func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText {
    let bech32String = Bech32Object.encode(m.ref.toBech32Object())
    
    let display_str: String = {
        switch m.ref.nip19 {
        case .npub(let pk): return getDisplayName(pk: pk, profiles: profiles)
        case .note: return abbrev_identifier(bech32String)
        case .nevent: return abbrev_identifier(bech32String)
        case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
        case .nrelay(let url): return url
        case .naddr: return abbrev_identifier(bech32String)
        case .nsec(let prv):
            guard let npub = privkey_to_pubkey(privkey: prv)?.npub else { return "nsec..." }
            return abbrev_identifier(npub)
        case .nscript(_): return bech32String
        }
    }()

    let display_str_with_at = "@\(display_str)"

    var attributedString = AttributedString(stringLiteral: display_str_with_at)
    attributedString.link = URL(string: "damus:nostr:\(bech32String)")
    attributedString.foregroundColor = DamusColors.purple
    
    return CompatibleText(attributed: attributedString)
}

// trim suffix whitespace and newlines
func trim_suffix(_ str: String) -> String {
    var result = str
    while result.last?.isWhitespace == true {
        result.removeLast()
    }
    return result
}

// trim prefix whitespace and newlines
func trim_prefix(_ str: String) -> String {
    var result = str
    while result.first?.isWhitespace == true {
        result.removeFirst()
    }
    return result
}

struct LongformContent {
    let markdown: MarkdownContent
    let words: Int

    /// Estimated reading time in minutes, based on average reading speed of 200 words per minute.
    var estimatedReadTimeMinutes: Int {
        return max(1, Int(ceil(Double(words) / 200.0)))
    }

    init(_ markdown: String) {
        // Pre-process markdown to ensure images are block-level (have blank lines around them)
        // This prevents images from being parsed as inline within text paragraphs
        let processedMarkdown = LongformContent.ensureBlockLevelImages(markdown)
        let blocks = [BlockNode].init(markdown: processedMarkdown)
        self.markdown = MarkdownContent(blocks: blocks)
        self.words = count_markdown_words(blocks: blocks)
    }

    /// Ensures markdown images have blank lines around them so they parse as block-level elements.
    /// Without blank lines, images followed by text are parsed as inline within a paragraph,
    /// causing them to be clipped by SwiftUI's Text view.
    ///
    /// Safety: This function excludes:
    /// - Fenced code blocks (``` or ~~~), indented code blocks (4 spaces/tab), and inline code
    /// - Images inside lists (lines starting with -, *, + followed by space)
    /// - Images inside blockquotes (lines starting with >)
    /// - Images inside tables (lines containing | with table structure)
    ///
    /// Known limitations (images remain inline, may clip if mixed with text):
    /// - Multi-line list items with images on continuation lines
    /// - Reference-style images: ![alt][id]
    /// - HTML <img> tags
    /// - Inline code with nested backticks (e.g., `` `code` `` using longer delimiters)
    private static func ensureBlockLevelImages(_ markdown: String) -> String {
        // First, identify regions to exclude (fenced code blocks and inline code)
        let excludedRanges = findExcludedRanges(in: markdown)

        // Pattern matches markdown images: ![alt](url) or ![alt](url "title")
        // Handles URLs with parentheses by matching balanced parens or escaped parens
        let imagePattern = #"!\[[^\]]*\]\((?:[^()]+|\([^)]*\))+(?:\s+"[^"]*")?\)"#
        guard let regex = try? NSRegularExpression(pattern: imagePattern, options: []) else {
            return markdown
        }

        var result = markdown
        let matches = regex.matches(in: result, options: [], range: NSRange(result.startIndex..., in: result))

        // Process matches in reverse order to preserve indices
        for match in matches.reversed() {
            guard let range = Range(match.range, in: result) else { continue }

            // Skip if this match is inside an excluded region (code block/inline code)
            // Use String.Index comparison for correctness with non-ASCII content
            if excludedRanges.contains(where: { $0.overlaps(range) }) {
                continue
            }

            // Skip if inside a list, blockquote, or table context
            if isInStructuredContext(result, at: range.lowerBound) {
                continue
            }

            let imageMarkdown = String(result[range])

            // Check what's before the image
            let beforeIndex = range.lowerBound
            let hasParagraphBreakBefore = beforeIndex == result.startIndex ||
                result[result.index(before: beforeIndex)] == "\n" && (
                    beforeIndex == result.index(after: result.startIndex) ||
                    result[result.index(beforeIndex, offsetBy: -2)] == "\n"
                )

            // Check what's after the image
            let afterIndex = range.upperBound
            let hasParagraphBreakAfter = afterIndex == result.endIndex ||
                result[afterIndex] == "\n" && (
                    result.index(after: afterIndex) == result.endIndex ||
                    result[result.index(after: afterIndex)] == "\n"
                )

            // Build replacement with proper paragraph breaks
            var replacement = imageMarkdown
            if !hasParagraphBreakBefore && beforeIndex != result.startIndex {
                replacement = "\n\n" + replacement
            }
            if !hasParagraphBreakAfter && afterIndex != result.endIndex {
                replacement = replacement + "\n\n"
            }

            if replacement != imageMarkdown {
                result.replaceSubrange(range, with: replacement)
            }
        }

        return result
    }

    /// Checks if the position is inside a list, blockquote, or table context.
    /// Returns true if modifying this position would break markdown structure.
    private static func isInStructuredContext(_ markdown: String, at position: String.Index) -> Bool {
        // Find the start of the current line
        var lineStart = position
        while lineStart > markdown.startIndex {
            let prevIndex = markdown.index(before: lineStart)
            if markdown[prevIndex] == "\n" {
                break
            }
            lineStart = prevIndex
        }

        // Get the line prefix (content before the image on this line)
        let linePrefix = String(markdown[lineStart..<position])
        let trimmedPrefix = linePrefix.trimmingCharacters(in: .whitespaces)

        // Check for list markers: -, *, + followed by space (to avoid false positives)
        if (trimmedPrefix.hasPrefix("- ") || trimmedPrefix.hasPrefix("* ") ||
            trimmedPrefix.hasPrefix("+ ")) {
            return true
        }

        // Check for numbered list: digit(s) followed by . or ) and space
        let numberedListPattern = #"^\d+[.)]\s"#
        if let numberedRegex = try? NSRegularExpression(pattern: numberedListPattern, options: []),
           numberedRegex.firstMatch(in: trimmedPrefix, options: [], range: NSRange(trimmedPrefix.startIndex..., in: trimmedPrefix)) != nil {
            return true
        }

        // Check for blockquote marker: >
        if trimmedPrefix.hasPrefix(">") {
            return true
        }

        // Check for table context: line has | at both start area and elsewhere (actual table row)
        var lineEnd = position
        while lineEnd < markdown.endIndex && markdown[lineEnd] != "\n" {
            lineEnd = markdown.index(after: lineEnd)
        }
        let fullLine = String(markdown[lineStart..<lineEnd])
        // Only treat as table if line has multiple | characters (actual table structure)
        if fullLine.filter({ $0 == "|" }).count >= 2 {
            return true
        }

        return false
    }

    /// Finds ranges in the markdown that should be excluded from image processing.
    /// Returns ranges as String.Index for correct handling of non-ASCII content.
    private static func findExcludedRanges(in markdown: String) -> [Range<String.Index>] {
        var ranges: [Range<String.Index>] = []
        let fullRange = NSRange(markdown.startIndex..., in: markdown)

        // Find fenced code blocks (3+ backticks or tildes)
        let fencedPattern = #"(?:^|\n)(`{3,}|~{3,}).*?(?:\n\1|\z)"#
        guard let fencedRegex = try? NSRegularExpression(pattern: fencedPattern, options: [.dotMatchesLineSeparators]) else {
            return ranges
        }
        ranges.append(contentsOf: fencedRegex.matches(in: markdown, options: [], range: fullRange)
            .compactMap { Range($0.range, in: markdown) })

        // Find indented code blocks (lines starting with 4 spaces or tab, preceded by blank line)
        // Blank line may contain whitespace: \n followed by optional spaces/tabs then \n
        let indentedPattern = #"(?:^|\n[ \t]*\n)((?:(?:    |\t).+\n?)+)"#
        guard let indentedRegex = try? NSRegularExpression(pattern: indentedPattern, options: []) else {
            return ranges
        }
        ranges.append(contentsOf: indentedRegex.matches(in: markdown, options: [], range: fullRange)
            .filter { $0.numberOfRanges > 1 }
            .compactMap { Range($0.range(at: 1), in: markdown) })

        // Find inline code (1+ backticks, matching pairs)
        let inlinePattern = #"(`+)(?!`)[^`]*?\1"#
        guard let inlineRegex = try? NSRegularExpression(pattern: inlinePattern, options: []) else {
            return ranges
        }
        ranges.append(contentsOf: inlineRegex.matches(in: markdown, options: [], range: fullRange)
            .compactMap { Range($0.range, in: markdown) })

        return ranges
    }
}

func count_markdown_words(blocks: [BlockNode]) -> Int {
    return blocks.reduce(0) { words, block in
        switch block {
        case .paragraph(let content):
            return words + count_inline_nodes_words(nodes: content)
        case .blockquote, .bulletedList, .numberedList, .taskList, .codeBlock, .htmlBlock, .heading, .table, .thematicBreak:
            return words
        }
    }
}

func count_words(_ s: String) -> Int {
    return s.components(separatedBy: .whitespacesAndNewlines).count
}

func count_inline_nodes_words(nodes: [InlineNode]) -> Int {
    return nodes.reduce(0) { words, node in
        switch node {
        case .text(let words):
            return count_words(words)
        case .emphasis(let children):
            return words + count_inline_nodes_words(nodes: children)
        case .strong(let children):
            return words + count_inline_nodes_words(nodes: children)
        case .strikethrough(let children):
            return words + count_inline_nodes_words(nodes: children)
        case .softBreak, .lineBreak, .code, .html, .image, .link:
            return words
        }
    }
}

enum NoteArtifacts {
    case separated(NoteArtifactsSeparated)
    case longform(LongformContent)

    var images: [URL] {
        switch self {
        case .separated(let arts):
            return arts.images
        case .longform:
            return []
        }
    }
}

enum UrlType {
    case media(MediaUrl)
    case link(URL)
    
    var url: URL {
        switch self {
        case .media(let media_url):
            switch media_url {
            case .image(let url):
                return url
            case .video(let url):
                return url
            }
        case .link(let url):
            return url
        }
    }
    
    var is_video: URL? {
        switch self {
        case .media(let media_url):
            switch media_url {
            case .image:
                return nil
            case .video(let url):
                return url
            }
        case .link:
            return nil
        }
    }
    
    var is_img: URL? {
        switch self {
        case .media(let media_url):
            switch media_url {
            case .image(let url):
                return url
            case .video:
                return nil
            }
        case .link:
            return nil
        }
    }
    
    var is_link: URL? {
        switch self {
        case .media:
            return nil
        case .link(let url):
            return url
        }
    }
    
    var is_media: MediaUrl? {
        switch self {
        case .media(let murl):
            return murl
        case .link:
            return nil
        }
    }
}

enum MediaUrl {
    case image(URL)
    case video(URL)
    
    var url: URL {
        switch self {
        case .image(let url):
            return url
        case .video(let url):
            return url
        }
    }
}
