package telegram import ( "fmt" "regexp" "strings" "github.com/mattn/go-runewidth" ) // --- Markdown to Telegram HTML conversion --- // Adapted from PicoClaw's telegram.go, extended with table support (matching TS "code" mode). // htmlTagToMarkdown converts common HTML tags in LLM output to markdown equivalents // so they survive the escapeHTML step and get re-converted by the markdown pipeline. var htmlToMdReplacers = []struct { re *regexp.Regexp repl string }{ {regexp.MustCompile(`(?i)`), "\n"}, {regexp.MustCompile(`(?i)`), "\n"}, {regexp.MustCompile(`(?i)([\s\S]*?)`), "**$1**"}, {regexp.MustCompile(`(?i)([\s\S]*?)`), "**$1**"}, // ${1}_ (not $1_) — Go regexp treats $1_ as a named group reference (identifier // characters include `_`), which drops the capture. Curly braces delimit the group. {regexp.MustCompile(`(?i)([\s\S]*?)`), "_${1}_"}, {regexp.MustCompile(`(?i)([\s\S]*?)`), "_${1}_"}, {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~$1~~"}, {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~$1~~"}, {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~$1~~"}, {regexp.MustCompile(`(?i)([\s\S]*?)`), "`$1`"}, {regexp.MustCompile(`(?i)]*>([\s\S]*?)`), "[$2]($1)"}, } func htmlTagToMarkdown(text string) string { for _, r := range htmlToMdReplacers { text = r.re.ReplaceAllString(text, r.repl) } return text } func markdownToTelegramHTML(text string) string { if text == "" { return "" } // Pre-process: convert any HTML tags in LLM output to markdown equivalents. // LLMs sometimes output raw HTML (e.g. bold) which would get escaped // by escapeHTML() and displayed as literal "bold" text. text = htmlTagToMarkdown(text) // Extract markdown tables FIRST — uses dedicated \x00TB placeholders. // Tables render as
 (monospace block) WITHOUT  wrapper,
	// so Telegram shows them as preformatted text, not as "code" with copy button.
	tables := extractMarkdownTables(text)
	text = tables.text

	// Extract and protect code blocks
	codeBlocks := extractCodeBlocks(text)
	text = codeBlocks.text

	// Extract and protect inline code
	inlineCodes := extractInlineCodes(text)
	text = inlineCodes.text


	// Extract and protect bare URLs from italic parsing.
	// URLs with underscores (e.g. syngas_dailymail_2026_ai) get broken by
	// the italic regex which matches _text_ patterns inside URLs.
	var urlPlaceholders []string
	reURL := regexp.MustCompile(`https?://[^\s<>\)\]]+`)
	text = reURL.ReplaceAllStringFunc(text, func(s string) string {
		idx := len(urlPlaceholders)
		urlPlaceholders = append(urlPlaceholders, s)
		return fmt.Sprintf("\x00URL%d\x00", idx)
	})
	// Strip markdown headers
	text = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1")

	// Strip blockquotes
	text = regexp.MustCompile(`(?m)^>\s*(.*)$`).ReplaceAllString(text, "$1")

	// Escape HTML
	text = escapeHTML(text)

	// Convert markdown links
	text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, `$1`)

	// Bold
	text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "$1")
	text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "$1")

	// Protect @mentions from italic conversion and convert to clickable Telegram links.
	// In HTML parse_mode, Telegram does NOT auto-link @username — we must use  tags.
	// Uses (^|\W) prefix to avoid matching emails like user@domain.com.
	var mentionPlaceholders []string
	reMention := regexp.MustCompile(`(^|\W)(@\w+)`)
	text = reMention.ReplaceAllStringFunc(text, func(s string) string {
		match := reMention.FindStringSubmatch(s)
		if len(match) < 3 {
			return s
		}
		idx := len(mentionPlaceholders)
		mentionPlaceholders = append(mentionPlaceholders, match[2])
		return match[1] + fmt.Sprintf("\x00MN%d\x00", idx)
	})

	// Italic
	reItalic := regexp.MustCompile(`_([^_]+)_`)
	text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
		match := reItalic.FindStringSubmatch(s)
		if len(match) < 2 {
			return s
		}
		return "" + match[1] + ""
	})

	// Strikethrough
	text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "$1")

	// Restore @mentions as plain text (protected from italic conversion above).
	// Do NOT wrap in  — LLM @mentions are not
	// necessarily Telegram usernames and auto-linking shows unwanted profile cards.
	for i, mention := range mentionPlaceholders {
		text = strings.ReplaceAll(text, fmt.Sprintf("\x00MN%d\x00", i), mention)
	}

	// List items
	text = regexp.MustCompile(`(?m)^[-*]\s+`).ReplaceAllString(text, "• ")

	// Restore bare URLs (protected from italic parsing above).
	for i, u := range urlPlaceholders {
		escaped := escapeHTML(u)
		text = strings.ReplaceAll(text, fmt.Sprintf("\x00URL%d\x00", i), escaped)
	}

	// Restore inline code
	for i, code := range inlineCodes.codes {
		escaped := escapeHTML(code)
		text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped))
	}

	// Restore code blocks (real code → 
)
	for i, code := range codeBlocks.codes {
		escaped := escapeHTML(code)
		text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("
%s
", escaped)) } // Restore tables (→
 only, no  wrapper)
	for i, table := range tables.rendered {
		escaped := escapeHTML(table)
		text = strings.ReplaceAll(text, fmt.Sprintf("\x00TB%d\x00", i), fmt.Sprintf("
%s
", escaped)) } return text } type codeBlockMatch struct { text string codes []string } func extractCodeBlocks(text string) codeBlockMatch { re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") matches := re.FindAllStringSubmatch(text, -1) codes := make([]string, 0, len(matches)) for _, match := range matches { codes = append(codes, match[1]) } i := 0 text = re.ReplaceAllStringFunc(text, func(_ string) string { placeholder := fmt.Sprintf("\x00CB%d\x00", i) i++ return placeholder }) return codeBlockMatch{text: text, codes: codes} } type inlineCodeMatch struct { text string codes []string } func extractInlineCodes(text string) inlineCodeMatch { re := regexp.MustCompile("`([^`]+)`") matches := re.FindAllStringSubmatch(text, -1) codes := make([]string, 0, len(matches)) for _, match := range matches { codes = append(codes, match[1]) } i := 0 text = re.ReplaceAllStringFunc(text, func(_ string) string { placeholder := fmt.Sprintf("\x00IC%d\x00", i) i++ return placeholder }) return inlineCodeMatch{text: text, codes: codes} } func escapeHTML(text string) string { text = strings.ReplaceAll(text, "&", "&") text = strings.ReplaceAll(text, "<", "<") text = strings.ReplaceAll(text, ">", ">") return text } // --- Markdown table extraction and rendering --- // tableLineRe matches a markdown table row: | col1 | col2 | ... var tableLineRe = regexp.MustCompile(`^\s*\|.*\|\s*$`) // tableSepRe matches a markdown table separator: |---|---| var tableSepRe = regexp.MustCompile(`^\s*\|[\s:]*-+[\s:]*(\|[\s:]*-+[\s:]*)*\|\s*$`) type tableMatch struct { text string // text with \x00TB0\x00 placeholders rendered []string // rendered ASCII tables (one per placeholder) } // extractMarkdownTables finds markdown tables, renders them as ASCII-aligned text, // and replaces them with \x00TBn\x00 placeholders. Tables are restored later as //
 (not 
) so Telegram shows them as preformatted text.
func extractMarkdownTables(text string) tableMatch {
	lines := strings.Split(text, "\n")
	var result []string
	var rendered []string
	idx := 0
	i := 0

	for i < len(lines) {
		// Look for table start: a table line followed by a separator line
		if i+1 < len(lines) && tableLineRe.MatchString(lines[i]) && tableSepRe.MatchString(lines[i+1]) {
			// Collect all contiguous table lines
			tableStart := i
			i++ // skip header
			i++ // skip separator
			for i < len(lines) && tableLineRe.MatchString(lines[i]) {
				i++
			}

			// Parse and render the table as ASCII-aligned text
			tableLines := lines[tableStart:i]
			rendered = append(rendered, renderTableAsCode(tableLines))
			result = append(result, fmt.Sprintf("\x00TB%d\x00", idx))
			idx++
		} else {
			result = append(result, lines[i])
			i++
		}
	}

	return tableMatch{text: strings.Join(result, "\n"), rendered: rendered}
}

// renderTableAsCode converts parsed markdown table lines into ASCII-aligned text.
// Matching TS renderTableAsCode(): calculates column widths, pads cells.
func renderTableAsCode(lines []string) string {
	if len(lines) < 2 {
		return strings.Join(lines, "\n")
	}

	// Parse all rows into cells (skip separator line at index 1)
	var rows [][]string
	for i, line := range lines {
		if i == 1 {
			continue // skip separator
		}
		rows = append(rows, parseTableRow(line))
	}

	if len(rows) == 0 {
		return ""
	}

	// Determine number of columns and max width per column
	numCols := 0
	for _, row := range rows {
		if len(row) > numCols {
			numCols = len(row)
		}
	}

	colWidths := make([]int, numCols)
	for _, row := range rows {
		for j := 0; j < numCols && j < len(row); j++ {
			w := displayWidth(row[j])
			if w > colWidths[j] {
				colWidths[j] = w
			}
		}
	}

	// Render header
	var out []string
	out = append(out, renderRow(rows[0], colWidths))

	// Render separator
	var sepParts []string
	for _, w := range colWidths {
		sepParts = append(sepParts, strings.Repeat("-", w+2))
	}
	out = append(out, "|"+strings.Join(sepParts, "|")+"|")

	// Render data rows
	for _, row := range rows[1:] {
		out = append(out, renderRow(row, colWidths))
	}

	return strings.Join(out, "\n")
}

// parseTableRow splits a markdown table row into trimmed cell strings.
// Inline markdown (bold, italic, strikethrough, code) is stripped since
// tables render inside 
 where HTML tags have no effect.
func parseTableRow(line string) []string {
	line = strings.TrimSpace(line)
	// Remove leading/trailing pipes
	if strings.HasPrefix(line, "|") {
		line = line[1:]
	}
	if strings.HasSuffix(line, "|") {
		line = line[:len(line)-1]
	}

	parts := strings.Split(line, "|")
	cells := make([]string, len(parts))
	for i, p := range parts {
		cells[i] = stripInlineMarkdown(strings.TrimSpace(p))
	}
	return cells
}

// stripInlineMarkdown removes common inline markdown markers from text.
// Used for table cells that render inside code blocks where formatting has no effect.
var (
	reStripBoldAsterisks    = regexp.MustCompile(`\*\*(.+?)\*\*`)
	reStripBoldUnderscores  = regexp.MustCompile(`__(.+?)__`)
	reStripItalicAsterisk   = regexp.MustCompile(`\*([^*]+)\*`)
	reStripItalicUnderscore = regexp.MustCompile(`_([^_]+)_`)
	reStripStrikethrough    = regexp.MustCompile(`~~(.+?)~~`)
	reStripInlineCode       = regexp.MustCompile("`([^`]+)`")
)

func stripInlineMarkdown(s string) string {
	s = reStripBoldAsterisks.ReplaceAllString(s, "$1")
	s = reStripBoldUnderscores.ReplaceAllString(s, "$1")
	s = reStripStrikethrough.ReplaceAllString(s, "$1")
	s = reStripInlineCode.ReplaceAllString(s, "$1")
	s = reStripItalicAsterisk.ReplaceAllString(s, "$1")
	s = reStripItalicUnderscore.ReplaceAllString(s, "$1")
	return s
}

// renderRow renders a single table row with padded cells.
func renderRow(cells []string, colWidths []int) string {
	var parts []string
	for j, w := range colWidths {
		cell := ""
		if j < len(cells) {
			cell = cells[j]
		}
		// Pad with spaces to align columns
		padding := max(w-displayWidth(cell), 0)
		parts = append(parts, " "+cell+strings.Repeat(" ", padding)+" ")
	}
	return "|" + strings.Join(parts, "|") + "|"
}

// displayWidth returns the display width of a string, accounting for
// East Asian wide characters (CJK), emoji, and other double-width glyphs.
// Uses go-runewidth which implements Unicode East Asian Width properly,
// unlike the naive utf8.RuneLen() approach which misclassifies Vietnamese
// diacritics (3-byte UTF-8 but single-width) as double-width.
func displayWidth(s string) int {
	return runewidth.StringWidth(s)
}

// --- Message chunking ---

// chunkHTML splits HTML text into chunks that fit within maxLen.
// Prefers splitting at paragraph boundaries (\n\n), then line boundaries (\n),
// then word boundaries (space). Matching TS chunkText() logic.
// chunkPlainText splits plain text into chunks that fit within maxLen,
// preferring to split at paragraph or line boundaries.
func chunkPlainText(text string, maxLen int) []string {
	return chunkHTML(text, maxLen)
}

func chunkHTML(text string, maxLen int) []string {
	if len(text) <= maxLen {
		return []string{text}
	}

	var chunks []string
	remaining := text

	for len(remaining) > 0 {
		if len(remaining) <= maxLen {
			chunks = append(chunks, remaining)
			break
		}

		// Strategy: search backwards for best natural breakpoint within maxLen.
		cutAt := maxLen

		// 1. Look for preferred boundaries: paragraph, then newline, then space.
		if idx := strings.LastIndex(remaining[:cutAt], "\n\n"); idx > 0 {
			cutAt = idx + 2
		} else if idx := strings.LastIndex(remaining[:cutAt], "\n"); idx > 0 {
			cutAt = idx + 1
		} else if idx := strings.LastIndex(remaining[:cutAt], " "); idx > 0 {
			cutAt = idx + 1
		}

		// 2. Safety: ensure we don't cut in the middle of an HTML tag or entity.
		// Tag check: find last '<' and see if it was closed before cutAt.
		if lastOpen := strings.LastIndex(remaining[:cutAt], "<"); lastOpen != -1 {
			lastClose := strings.LastIndex(remaining[:cutAt], ">")
			if lastOpen > lastClose {
				// We're inside a tag (e.g. " lastClose {
				// Inside an entity (e.g. "&am"). Move cutAt back to start of entity.
				cutAt = lastOpen
			}
		}

		// 3. Fallback for monolithic blocks: if boundaries or safety moved cutAt to 0,
		// force progress by using maxLen anyway. This avoids infinite loops.
		if cutAt <= 0 {
			cutAt = maxLen
		}

		chunks = append(chunks, strings.TrimRight(remaining[:cutAt], " \n"))
		remaining = strings.TrimLeft(remaining[cutAt:], " \n")
	}

	return chunks
}