//
//  ConversationExportService.swift
//  Confab
//
//  Shared conversation export: Markdown, HTML, and PDF
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.


import AppKit
import Foundation
import WebKit

enum ConversationExportService {

    // MARK: - Markdown

    nonisolated static func markdown(messages: [Message]) -> String {
        messages.map { msg in
            let header = msg.role == .user ? "**User**" : "**Assistant**"
            return "\(header)\n\n\(msg.content)"
        }.joined(separator: "\n\n---\n\n")
    }

    // MARK: - HTML

    nonisolated private static let css = """
    :root { color-scheme: light dark; }
    html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
    ::-webkit-scrollbar { display: none; }
    body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \
    font-size: 14px; color: #1a1a1a; background: #ffffff; max-width: 820px; margin: 40px auto; \
    padding: 0 24px; line-height: 1.5; }
    h1 { font-size: 22px; border-bottom: 1px solid #ddd; padding-bottom: 12px; }
    .message { margin: 20px 0; padding: 12px 16px; border-radius: 8px; border-left: 4px solid transparent; \
    break-inside: avoid; page-break-inside: avoid; }
    .message.user { background: #f5f7fa; border-left-color: #4a90d9; }
    .message.assistant { background: #fafafa; border-left-color: #8a8a8a; }
    .role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; \
    color: #666; margin-bottom: 8px; }
    .message p { margin: 8px 0; }
    .message h1 { font-size: 19px; margin: 12px 0 6px; }
    .message h2 { font-size: 17px; margin: 12px 0 6px; }
    .message h3 { font-size: 15.5px; margin: 12px 0 6px; }
    .message h4, .message h5, .message h6 { font-size: 14px; margin: 12px 0 6px; }
    .message ul, .message ol { margin: 8px 0; padding-left: 24px; }
    .message blockquote { margin: 8px 0; padding: 4px 12px; border-left: 3px solid #ccc; color: #555; }
    .message code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; \
    background: #eef0f2; padding: 1px 5px; border-radius: 4px; }
    .message pre { background: #1e1e1e; color: #e8e8e8; padding: 12px 14px; border-radius: 6px; \
    overflow-x: auto; }
    .message pre code { background: none; padding: 0; color: inherit; }
    .message hr { border: none; border-top: 1px solid #ddd; margin: 16px 0; }
    .message a { color: #4a90d9; }
    .message table { border-collapse: collapse; margin: 10px 0; width: 100%; }
    .message th, .message td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; }
    .message th { background: #f0f2f4; font-weight: 600; }
    @media (prefers-color-scheme: dark) {
        body { color: #e8e8e8; background: #1c1c1e; }
        h1 { border-bottom-color: #3a3a3c; }
        .message.user { background: #24303d; border-left-color: #5aa2f0; }
        .message.assistant { background: #262626; border-left-color: #9a9a9a; }
        .role { color: #a8a8a8; }
        .message blockquote { border-left-color: #555; color: #bbb; }
        .message code { background: #2e2e2e; color: #e0e0e0; }
        .message hr { border-top-color: #3a3a3c; }
        .message a { color: #6fb1f0; }
        .message th, .message td { border-color: #3a3a3c; }
        .message th { background: #2a2a2c; }
    }
    """

    // PDF is baked at export time — it can't respond to prefers-color-scheme live like an
    // HTML file opened in a browser can, and empirically the print pipeline ignores that media
    // query entirely regardless (confirmed: identical CSS printed light even with the webview
    // forced into dark appearance). So PDF gets its own always-dark stylesheet, applied
    // unconditionally rather than behind a media query. print-color-adjust: exact is required
    // too — browsers/WebKit's print pipeline strips background colors by default to save ink
    // unless told otherwise; without it every background here silently prints white.
    nonisolated private static let pdfCss = """
    * { -webkit-print-color-adjust: exact; print-color-adjust: exact; color-adjust: exact; }
    :root { color-scheme: dark; }
    html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
    ::-webkit-scrollbar { display: none; }
    body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \
    font-size: 14px; color: #e8e8e8; background: #1c1c1e; max-width: 820px; margin: 40px auto; \
    padding: 0 24px; line-height: 1.5; }
    h1 { font-size: 22px; border-bottom: 1px solid #3a3a3c; padding-bottom: 12px; }
    .message { margin: 20px 0; padding: 12px 16px; border-radius: 8px; border-left: 4px solid transparent; \
    break-inside: avoid; page-break-inside: avoid; }
    .message.user { background: #24303d; border-left-color: #5aa2f0; }
    .message.assistant { background: #262626; border-left-color: #9a9a9a; }
    .role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; \
    color: #a8a8a8; margin-bottom: 8px; }
    .message p { margin: 8px 0; }
    .message h1 { font-size: 19px; margin: 12px 0 6px; }
    .message h2 { font-size: 17px; margin: 12px 0 6px; }
    .message h3 { font-size: 15.5px; margin: 12px 0 6px; }
    .message h4, .message h5, .message h6 { font-size: 14px; margin: 12px 0 6px; }
    .message ul, .message ol { margin: 8px 0; padding-left: 24px; }
    .message blockquote { margin: 8px 0; padding: 4px 12px; border-left: 3px solid #555; color: #bbb; }
    .message code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; \
    background: #2e2e2e; color: #e0e0e0; padding: 1px 5px; border-radius: 4px; }
    .message pre { background: #111; color: #e8e8e8; padding: 12px 14px; border-radius: 6px; \
    overflow-x: auto; }
    .message pre code { background: none; padding: 0; color: inherit; }
    .message hr { border: none; border-top: 1px solid #3a3a3c; margin: 16px 0; }
    .message a { color: #6fb1f0; }
    .message table { border-collapse: collapse; margin: 10px 0; width: 100%; }
    .message th, .message td { border: 1px solid #3a3a3c; padding: 6px 10px; text-align: left; }
    .message th { background: #2a2a2c; font-weight: 600; }
    """

    nonisolated static func html(name: String, messages: [Message], forceDarkCSS: Bool = false) -> String {
        let body = messages.map { msg -> String in
            let roleLabel = msg.role == .user ? "User" : "Assistant"
            let roleClass = msg.role == .user ? "user" : "assistant"
            let rendered = renderMarkdownBody(htmlEscape(msg.content))
            return """
            <div class="message \(roleClass)">
            <div class="role">\(roleLabel)</div>
            \(rendered)
            </div>
            """
        }.joined(separator: "\n")

        return """
        <!DOCTYPE html>
        <html lang="en">
        <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>\(htmlEscape(name))</title>
        <style>\(forceDarkCSS ? pdfCss : css)</style>
        </head>
        <body>
        <h1>\(htmlEscape(name))</h1>
        \(body)
        </body>
        </html>
        """
    }

    // MARK: - PDF

    enum PDFError: LocalizedError {
        case generationFailed
        var errorDescription: String? { "Failed to generate PDF" }
    }

    /// Uses WKWebView's real print pipeline (NSPrintOperation), not createPDF(). Confirmed
    /// empirically by comparing against a normal reference PDF: createPDF() never paginates —
    /// it captures the webview's frame verbatim, or auto-grows to ONE continuous page matching
    /// full content height for overflow (a single page many thousands of points tall for a long
    /// conversation). That's a fundamentally different, non-standard document shape from any
    /// normal PDF, which is what actually made ordinarily-sized text read as "huge" — there was
    /// no page of a familiar size to judge it against. Printing through NSPrintOperation with a
    /// real A4 paper size gives genuine multi-page pagination, matching what any other app's
    /// "export/print to PDF" produces.
    static func pdfData(name: String, messages: [Message]) async throws -> Data {
        let htmlString = html(name: name, messages: messages, forceDarkCSS: true)
        let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 850, height: 1100))
        webView.pageZoom = 1.0
        let delegate = PDFLoadDelegate()
        webView.navigationDelegate = delegate
        try await delegate.load(htmlString, in: webView)

        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".pdf")
        defer { try? FileManager.default.removeItem(at: tempURL) }

        let printInfo = NSPrintInfo()
        printInfo.paperSize = NSSize(width: 595, height: 842) // A4
        printInfo.topMargin = 36
        printInfo.bottomMargin = 36
        printInfo.leftMargin = 36
        printInfo.rightMargin = 36
        printInfo.horizontalPagination = .fit
        printInfo.jobDisposition = .save
        printInfo.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = tempURL

        let printOp = webView.printOperation(with: printInfo)
        printOp.showsPrintPanel = false
        printOp.showsProgressPanel = false

        // NSPrintOperation.run() blocks synchronously, so it's dispatched off the main actor
        // to avoid freezing the UI while a long conversation paginates.
        let success = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
            DispatchQueue.global(qos: .userInitiated).async {
                continuation.resume(returning: printOp.run())
            }
        }
        guard success, let data = try? Data(contentsOf: tempURL) else {
            throw PDFError.generationFailed
        }
        return data
    }

    private final class PDFLoadDelegate: NSObject, WKNavigationDelegate {
        private var continuation: CheckedContinuation<Void, Error>?

        func load(_ html: String, in webView: WKWebView) async throws {
            try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
                self.continuation = cont
                webView.loadHTMLString(html, baseURL: nil)
            }
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            continuation?.resume()
            continuation = nil
        }

        func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
            continuation?.resume(throwing: error)
            continuation = nil
        }

        func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
            continuation?.resume(throwing: error)
            continuation = nil
        }
    }

    // MARK: - File writing

    nonisolated static func writeToDownloads(_ content: String, filename: String) -> URL? {
        let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
            ?? FileManager.default.temporaryDirectory
        let fileURL = downloads.appendingPathComponent(filename)
        do {
            try content.write(to: fileURL, atomically: true, encoding: .utf8)
            return fileURL
        } catch {
            return nil
        }
    }

    nonisolated static func writeToDownloads(_ data: Data, filename: String) -> URL? {
        let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
            ?? FileManager.default.temporaryDirectory
        let fileURL = downloads.appendingPathComponent(filename)
        do {
            try data.write(to: fileURL, options: .atomic)
            return fileURL
        } catch {
            return nil
        }
    }

    // MARK: - Markdown → HTML rendering (scoped to what chat messages actually contain —
    // headers, bold/italic, inline code, fenced code blocks, lists, blockquotes, links,
    // horizontal rules, tables, paragraphs. Not full CommonMark/GFM.)

    nonisolated private static func htmlEscape(_ text: String) -> String {
        var result = text
        result = result.replacingOccurrences(of: "&", with: "&amp;")
        result = result.replacingOccurrences(of: "<", with: "&lt;")
        result = result.replacingOccurrences(of: ">", with: "&gt;")
        result = result.replacingOccurrences(of: "\"", with: "&quot;")
        result = result.replacingOccurrences(of: "'", with: "&#39;")
        return result
    }

    /// Renders already-HTML-escaped markdown text into an HTML body fragment.
    nonisolated static func renderMarkdownBody(_ escapedContent: String) -> String {
        let lines = escapedContent.components(separatedBy: "\n")
        var html = ""
        var index = 0
        var paragraphLines: [String] = []
        var listBuffer: [String] = []
        var listTag: String?

        func flushParagraph() {
            guard !paragraphLines.isEmpty else { return }
            let joined = paragraphLines.joined(separator: "<br>\n")
            html += "<p>\(renderInline(joined))</p>\n"
            paragraphLines = []
        }

        func flushList() {
            guard let tag = listTag, !listBuffer.isEmpty else { return }
            html += "<\(tag)>\n"
            for item in listBuffer { html += "<li>\(renderInline(item))</li>\n" }
            html += "</\(tag)>\n"
            listBuffer = []
            listTag = nil
        }

        while index < lines.count {
            let line = lines[index].trimmingCharacters(in: .whitespaces)

            // Fenced code block
            if line.hasPrefix("```") {
                flushParagraph(); flushList()
                let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
                var codeLines: [String] = []
                index += 1
                while index < lines.count, !lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
                    codeLines.append(lines[index])
                    index += 1
                }
                let classAttr = lang.isEmpty ? "" : " class=\"language-\(lang)\""
                html += "<pre><code\(classAttr)>\(codeLines.joined(separator: "\n"))</code></pre>\n"
                if index < lines.count { index += 1 } // skip closing ```
                continue
            }

            // Headers
            if let header = headerMatch(line) {
                flushParagraph(); flushList()
                html += "<h\(header.level)>\(renderInline(header.text))</h\(header.level)>\n"
                index += 1
                continue
            }

            // Horizontal rule
            if isHorizontalRule(line) {
                flushParagraph(); flushList()
                html += "<hr>\n"
                index += 1
                continue
            }

            // GFM-style pipe table: a row line immediately followed by a valid separator row
            if line.contains("|"), index + 1 < lines.count,
               isTableSeparatorRow(lines[index + 1].trimmingCharacters(in: .whitespaces)) {
                flushParagraph(); flushList()
                let headerCells = splitTableRow(line)
                let alignments = tableAlignments(from: lines[index + 1].trimmingCharacters(in: .whitespaces))
                index += 2
                var bodyRows: [[String]] = []
                while index < lines.count {
                    let rowLine = lines[index].trimmingCharacters(in: .whitespaces)
                    guard rowLine.contains("|"), !rowLine.isEmpty else { break }
                    bodyRows.append(splitTableRow(rowLine))
                    index += 1
                }
                html += renderTable(headerCells: headerCells, alignments: alignments, bodyRows: bodyRows)
                continue
            }

            // Blockquote (escaped ">" is "&gt;")
            if line.hasPrefix("&gt; ") || line == "&gt;" {
                flushParagraph(); flushList()
                var quoteLines: [String] = []
                while index < lines.count {
                    let quoteLine = lines[index].trimmingCharacters(in: .whitespaces)
                    if quoteLine.hasPrefix("&gt; ") {
                        quoteLines.append(String(quoteLine.dropFirst(5)))
                    } else if quoteLine == "&gt;" {
                        quoteLines.append("")
                    } else {
                        break
                    }
                    index += 1
                }
                html += "<blockquote><p>\(renderInline(quoteLines.joined(separator: "<br>\n")))</p></blockquote>\n"
                continue
            }

            // Unordered list
            if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") {
                flushParagraph()
                if listTag == "ol" { flushList() }
                listTag = "ul"
                listBuffer.append(String(line.dropFirst(2)))
                index += 1
                continue
            }

            // Ordered list
            if let text = orderedListMatch(line) {
                flushParagraph()
                if listTag == "ul" { flushList() }
                listTag = "ol"
                listBuffer.append(text)
                index += 1
                continue
            }

            // Blank line — paragraph/list separator
            if line.isEmpty {
                flushParagraph(); flushList()
                index += 1
                continue
            }

            // Plain paragraph text
            flushList()
            paragraphLines.append(line)
            index += 1
        }

        flushParagraph()
        flushList()
        return html
    }

    nonisolated private static func headerMatch(_ line: String) -> (level: Int, text: String)? {
        var level = 0
        var idx = line.startIndex
        while idx < line.endIndex, line[idx] == "#", level < 6 {
            level += 1
            idx = line.index(after: idx)
        }
        guard level > 0, idx < line.endIndex, line[idx] == " " else { return nil }
        return (level, String(line[line.index(after: idx)...]))
    }

    nonisolated private static func orderedListMatch(_ line: String) -> String? {
        guard let dotRange = line.range(of: ". ") else { return nil }
        let prefix = line[line.startIndex..<dotRange.lowerBound]
        guard !prefix.isEmpty, prefix.allSatisfy(\.isNumber) else { return nil }
        return String(line[dotRange.upperBound...])
    }

    nonisolated private static func isHorizontalRule(_ line: String) -> Bool {
        guard line.count >= 3 else { return false }
        return line.allSatisfy { $0 == "-" } || line.allSatisfy { $0 == "*" } || line.allSatisfy { $0 == "_" }
    }

    /// A GFM table separator row looks like `| --- | :--: | ---: |` — pipe-delimited cells
    /// made up of dashes with optional leading/trailing colons for alignment.
    nonisolated private static func isTableSeparatorRow(_ line: String) -> Bool {
        guard line.contains("-") else { return false }
        let cells = splitTableRow(line)
        guard !cells.isEmpty else { return false }
        return cells.allSatisfy { cell in
            var core = cell.trimmingCharacters(in: .whitespaces)
            guard !core.isEmpty else { return false }
            if core.hasPrefix(":") { core.removeFirst() }
            if core.hasSuffix(":") { core.removeLast() }
            return !core.isEmpty && core.allSatisfy { $0 == "-" }
        }
    }

    nonisolated private static func splitTableRow(_ line: String) -> [String] {
        var trimmed = line.trimmingCharacters(in: .whitespaces)
        if trimmed.hasPrefix("|") { trimmed.removeFirst() }
        if trimmed.hasSuffix("|") { trimmed.removeLast() }
        return trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
    }

    nonisolated private static func tableAlignments(from separatorLine: String) -> [String] {
        splitTableRow(separatorLine).map { cell in
            let left = cell.hasPrefix(":")
            let right = cell.hasSuffix(":")
            if left && right { return "center" }
            if right { return "right" }
            if left { return "left" }
            return ""
        }
    }

    nonisolated private static func renderTable(headerCells: [String], alignments: [String], bodyRows: [[String]]) -> String {
        func alignAttr(_ i: Int) -> String {
            guard i < alignments.count, !alignments[i].isEmpty else { return "" }
            return " style=\"text-align:\(alignments[i])\""
        }
        var html = "<table>\n<thead>\n<tr>\n"
        for (i, cell) in headerCells.enumerated() {
            html += "<th\(alignAttr(i))>\(renderInline(cell))</th>\n"
        }
        html += "</tr>\n</thead>\n<tbody>\n"
        for row in bodyRows {
            html += "<tr>\n"
            for (i, cell) in row.enumerated() {
                html += "<td\(alignAttr(i))>\(renderInline(cell))</td>\n"
            }
            html += "</tr>\n"
        }
        html += "</tbody>\n</table>\n"
        return html
    }

    /// Applies inline markdown (code, links, bold, italic) to an already-HTML-escaped line.
    nonisolated private static func renderInline(_ escapedText: String) -> String {
        var text = escapedText
        var codeSpans: [String] = []

        text = replacingCaptures(text, pattern: #"`([^`]+?)`"#) { match in
            let token = "\u{E000}\(codeSpans.count)\u{E000}"
            codeSpans.append("<code>\(match)</code>")
            return token
        }

        text = replacingCaptures(text, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#, groups: 2) { groups in
            "<a href=\"\(groups[1])\">\(groups[0])</a>"
        }

        text = replacingCaptures(text, pattern: #"\*\*([^*]+?)\*\*"#) { "<strong>\($0)</strong>" }
        text = replacingCaptures(text, pattern: #"__([^_]+?)__"#) { "<strong>\($0)</strong>" }
        text = replacingCaptures(text, pattern: #"\*([^*]+?)\*"#) { "<em>\($0)</em>" }

        for (i, span) in codeSpans.enumerated() {
            text = text.replacingOccurrences(of: "\u{E000}\(i)\u{E000}", with: span)
        }
        return text
    }

    nonisolated private static func replacingCaptures(
        _ text: String,
        pattern: String,
        transform: @escaping (String) -> String
    ) -> String {
        guard let regex = try? Regex(pattern) else { return text }
        return text.replacing(regex) { match in
            transform(match.output.count > 1 ? String(match.output[1].substring ?? "") : "")
        }
    }

    nonisolated private static func replacingCaptures(
        _ text: String,
        pattern: String,
        groups: Int,
        transform: @escaping ([String]) -> String
    ) -> String {
        guard let regex = try? Regex(pattern) else { return text }
        return text.replacing(regex) { match in
            let captured = (1...groups).map { i in
                match.output.count > i ? String(match.output[i].substring ?? "") : ""
            }
            return transform(captured)
        }
    }
}
