package docs

import (
	"archive/zip"
	"bytes"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"

	"gitea.parodia.dev/drwily/petal/internal/db"
)

// exportRoutes registers the download endpoints: one document
// (GET /api/docs/{id}/export?format=…) and a whole-corpus backup zip
// (GET /api/docs/export-all?format=…). The static "export-all" segment takes
// priority over the {id} param in chi's router, so the two don't collide.
func (h *Handler) exportRoutes(r chi.Router) {
	r.Get("/{id}/export", h.export)
	r.Get("/export-all", h.exportAll)
}

// exportAll streams every document the user owns, each rendered in the requested
// format, bundled into a single zip — a one-click "download all my writing"
// backup. Per-doc version history guards against bad edits; this guards against
// a lost disk. Reuses the same renderers as the single-document export.
func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
	formatKey := r.URL.Query().Get("format")
	if formatKey == "" {
		formatKey = "md"
	}
	format, ok := exportFormats[formatKey]
	if !ok {
		badRequest(w, "unsupported export format")
		return
	}

	rows, err := h.DB.Query(
		`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
		   FROM documents
		  WHERE user_id = ?
		  ORDER BY updated_at DESC`,
		db.LocalUserID,
	)
	if err != nil {
		serverError(w, err)
		return
	}
	defer rows.Close()

	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	seen := map[string]int{} // de-duplicate filenames from same-titled docs
	for rows.Next() {
		var doc db.Document
		if err := rows.Scan(
			&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
			&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
		); err != nil {
			serverError(w, err)
			return
		}
		body, err := format.render(doc)
		if err != nil {
			serverError(w, err)
			return
		}
		base := sanitizeFilename(doc.Title)
		if base == "" {
			base = "untitled"
		}
		key := base + "." + format.ext
		name := key
		if c := seen[key]; c > 0 {
			name = fmt.Sprintf("%s (%d).%s", base, c, format.ext)
		}
		seen[key]++
		f, err := zw.Create(name)
		if err != nil {
			serverError(w, err)
			return
		}
		if _, err := f.Write(body); err != nil {
			serverError(w, err)
			return
		}
	}
	if err := rows.Err(); err != nil {
		serverError(w, err)
		return
	}
	if err := zw.Close(); err != nil {
		serverError(w, err)
		return
	}

	filename := "petal-backup-" + time.Now().Format("2006-01-02") + ".zip"
	w.Header().Set("Content-Type", "application/zip")
	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
	w.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len()))
	_, _ = w.Write(buf.Bytes())
}

// exportFormat describes one downloadable format: how to render it and how to
// label the resulting file.
type exportFormat struct {
	ext         string
	contentType string
	render      func(doc db.Document) ([]byte, error)
}

// exportFormats is the supported set. PDF is intentionally absent: a faithful,
// CJK-safe PDF needs an embedded Unicode font (~10MB into the single binary) or
// a headless browser (breaks the no-cgo, single-binary story). The frontend
// offers "Print / Save as PDF" via the browser instead, which uses the reader's
// own fonts and renders CJK correctly for free.
var exportFormats = map[string]exportFormat{
	"md":   {ext: "md", contentType: "text/markdown; charset=utf-8", render: renderMarkdown},
	"html": {ext: "html", contentType: "text/html; charset=utf-8", render: renderHTMLFile},
	"txt":  {ext: "txt", contentType: "text/plain; charset=utf-8", render: renderPlainText},
	"docx": {
		ext:         "docx",
		contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
		render:      renderDocx,
	},
}

// export streams the document in the requested format as a download.
func (h *Handler) export(w http.ResponseWriter, r *http.Request) {
	formatKey := r.URL.Query().Get("format")
	if formatKey == "" {
		formatKey = "md"
	}
	format, ok := exportFormats[formatKey]
	if !ok {
		badRequest(w, "unsupported export format")
		return
	}

	doc, err := h.fetch(chi.URLParam(r, "id"))
	if errors.Is(err, sql.ErrNoRows) {
		notFound(w)
		return
	}
	if err != nil {
		serverError(w, err)
		return
	}

	body, err := format.render(doc)
	if err != nil {
		serverError(w, err)
		return
	}

	filename := sanitizeFilename(doc.Title) + "." + format.ext
	w.Header().Set("Content-Type", format.contentType)
	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
	w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
	_, _ = w.Write(body)
}

// --- Tiptap document model --------------------------------------------------

// pmNode is one ProseMirror/Tiptap node. The tree is what the editor stores in
// Document.Content; we walk it to render every export format from one source.
type pmNode struct {
	Type    string         `json:"type"`
	Attrs   map[string]any `json:"attrs"`
	Marks   []pmMark       `json:"marks"`
	Text    string         `json:"text"`
	Content []pmNode       `json:"content"`
}

type pmMark struct {
	Type  string         `json:"type"`
	Attrs map[string]any `json:"attrs"`
}

// parseDoc decodes Document.Content into a node tree. On empty or malformed JSON
// it falls back to wrapping the plain-text mirror in paragraphs, so export never
// fails just because the editor state is unusual.
func parseDoc(doc db.Document) pmNode {
	var root pmNode
	if err := json.Unmarshal([]byte(doc.Content), &root); err != nil || root.Type == "" {
		return fallbackDoc(doc.ContentText)
	}
	if len(root.Content) == 0 && strings.TrimSpace(doc.ContentText) != "" {
		return fallbackDoc(doc.ContentText)
	}
	return root
}

// fallbackDoc builds a minimal doc node from plain text, one paragraph per line.
func fallbackDoc(text string) pmNode {
	root := pmNode{Type: "doc"}
	for _, line := range strings.Split(text, "\n") {
		p := pmNode{Type: "paragraph"}
		if line != "" {
			p.Content = []pmNode{{Type: "text", Text: line}}
		}
		root.Content = append(root.Content, p)
	}
	return root
}

func (n pmNode) hasMark(t string) bool {
	for _, m := range n.Marks {
		if m.Type == t {
			return true
		}
	}
	return false
}

// attrStr returns a string-valued attribute (e.g. an image src), or "".
func (n pmNode) attrStr(key string) string {
	if n.Attrs == nil {
		return ""
	}
	if s, ok := n.Attrs[key].(string); ok {
		return s
	}
	return ""
}

// markAttr returns a string attribute from the named mark (e.g. a link href).
func (n pmNode) markAttr(markType, key string) string {
	for _, m := range n.Marks {
		if m.Type == markType && m.Attrs != nil {
			if s, ok := m.Attrs[key].(string); ok {
				return s
			}
		}
	}
	return ""
}

func (n pmNode) level() int {
	if n.Attrs == nil {
		return 1
	}
	if l, ok := n.Attrs["level"].(float64); ok && l >= 1 {
		return int(l)
	}
	return 1
}

// --- Markdown ---------------------------------------------------------------

func renderMarkdown(doc db.Document) ([]byte, error) {
	root := parseDoc(doc)
	var blocks []string
	for _, child := range root.Content {
		if s := mdBlock(child, 0); s != "" {
			blocks = append(blocks, s)
		}
	}
	out := "# " + doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
	return []byte(out), nil
}

func mdBlock(n pmNode, depth int) string {
	switch n.Type {
	case "heading":
		return strings.Repeat("#", n.level()) + " " + mdInline(n.Content)
	case "paragraph":
		return mdInline(n.Content)
	case "blockquote":
		var lines []string
		for _, c := range n.Content {
			for _, l := range strings.Split(mdBlock(c, depth), "\n") {
				lines = append(lines, "> "+l)
			}
		}
		return strings.Join(lines, "\n")
	case "codeBlock":
		return "```\n" + textContent(n) + "\n```"
	case "horizontalRule":
		return "---"
	case "image":
		alt := n.attrStr("alt")
		return fmt.Sprintf("![%s](%s)", alt, n.attrStr("src"))
	case "table":
		return mdTable(n)
	case "bulletList", "orderedList":
		var items []string
		for i, item := range n.Content {
			marker := "- "
			if n.Type == "orderedList" {
				marker = fmt.Sprintf("%d. ", i+1)
			}
			indent := strings.Repeat("  ", depth)
			// A listItem holds block children (usually one paragraph).
			var parts []string
			for _, c := range item.Content {
				parts = append(parts, mdBlock(c, depth+1))
			}
			items = append(items, indent+marker+strings.TrimSpace(strings.Join(parts, "\n")))
		}
		return strings.Join(items, "\n")
	default:
		// Unknown block: render any inline text it carries.
		if len(n.Content) > 0 {
			return mdInline(n.Content)
		}
		return ""
	}
}

func mdInline(nodes []pmNode) string {
	var b strings.Builder
	for _, n := range nodes {
		switch n.Type {
		case "text":
			b.WriteString(applyMdMarks(n))
		case "hardBreak":
			b.WriteString("  \n")
		default:
			b.WriteString(mdInline(n.Content))
		}
	}
	return b.String()
}

// mdTable renders a table node as a GitHub-flavored Markdown table. The first
// row is used as the header (Markdown tables require one); a separator row is
// inserted after it. Cell text is flattened to inline Markdown.
func mdTable(n pmNode) string {
	var rows [][]string
	for _, row := range n.Content {
		if row.Type != "tableRow" {
			continue
		}
		var cells []string
		for _, cell := range row.Content {
			// A cell holds block children (usually one paragraph); flatten them.
			var parts []string
			for _, c := range cell.Content {
				parts = append(parts, mdInline(c.Content))
			}
			// Escape pipes so cell content doesn't break the column layout.
			cells = append(cells, strings.ReplaceAll(strings.TrimSpace(strings.Join(parts, " ")), "|", "\\|"))
		}
		rows = append(rows, cells)
	}
	if len(rows) == 0 {
		return ""
	}
	cols := 0
	for _, r := range rows {
		if len(r) > cols {
			cols = len(r)
		}
	}
	pad := func(r []string) string {
		for len(r) < cols {
			r = append(r, "")
		}
		return "| " + strings.Join(r, " | ") + " |"
	}
	var lines []string
	lines = append(lines, pad(rows[0]))
	sep := make([]string, cols)
	for i := range sep {
		sep[i] = "---"
	}
	lines = append(lines, "| "+strings.Join(sep, " | ")+" |")
	for _, r := range rows[1:] {
		lines = append(lines, pad(r))
	}
	return strings.Join(lines, "\n")
}

func applyMdMarks(n pmNode) string {
	t := n.Text
	if n.hasMark("code") {
		return "`" + t + "`" // code spans don't combine with other emphasis
	}
	if n.hasMark("bold") {
		t = "**" + t + "**"
	}
	if n.hasMark("italic") {
		t = "*" + t + "*"
	}
	if n.hasMark("strike") {
		t = "~~" + t + "~~"
	}
	if n.hasMark("underline") {
		t = "<u>" + t + "</u>"
	}
	if href := n.markAttr("link", "href"); href != "" {
		t = "[" + t + "](" + href + ")"
	}
	return t
}

// --- Plain text -------------------------------------------------------------

func renderPlainText(doc db.Document) ([]byte, error) {
	root := parseDoc(doc)
	var blocks []string
	for _, child := range root.Content {
		if s := txtBlock(child); s != "" {
			blocks = append(blocks, s)
		}
	}
	out := doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
	return []byte(out), nil
}

func txtBlock(n pmNode) string {
	switch n.Type {
	case "bulletList", "orderedList":
		var items []string
		for i, item := range n.Content {
			marker := "• "
			if n.Type == "orderedList" {
				marker = fmt.Sprintf("%d. ", i+1)
			}
			items = append(items, marker+strings.TrimSpace(textContent(item)))
		}
		return strings.Join(items, "\n")
	case "horizontalRule":
		return "----------"
	default:
		return textContent(n)
	}
}

// textContent flattens all descendant text, joining hardBreaks as newlines.
func textContent(n pmNode) string {
	if n.Type == "text" {
		return n.Text
	}
	if n.Type == "hardBreak" {
		return "\n"
	}
	var b strings.Builder
	for _, c := range n.Content {
		b.WriteString(textContent(c))
	}
	return b.String()
}

// --- HTML -------------------------------------------------------------------

func renderHTMLFile(doc db.Document) ([]byte, error) {
	root := parseDoc(doc)
	var body strings.Builder
	for _, child := range root.Content {
		body.WriteString(htmlBlock(child))
	}
	page := fmt.Sprintf(htmlTemplate, htmlEscape(doc.Title), htmlEscape(doc.Title), body.String())
	return []byte(page), nil
}

// htmlTemplate is a standalone, self-contained page with a warm, readable
// stylesheet and a CJK-first font stack so exported writing looks like Petal,
// not a raw dump. No external assets — opens offline anywhere.
const htmlTemplate = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s</title>
<style>
  :root { color-scheme: light; }
  body {
    font-family: "Georgia", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", serif;
    line-height: 1.75; color: #463a3f; background: #fffafb;
    max-width: 42rem; margin: 3rem auto; padding: 0 1.5rem;
  }
  h1, h2, h3 { font-family: "Georgia", "Songti SC", serif; color: #b04a6a; line-height: 1.3; }
  h1 { font-size: 1.9rem; border-bottom: 2px solid #f6d6e0; padding-bottom: .4rem; }
  blockquote { border-left: 3px solid #f3b6c8; margin: 1rem 0; padding: .2rem 1rem; color: #6b5860; background: #fff2f6; }
  code { background: #fdeef3; padding: .1rem .35rem; border-radius: .3rem; font-size: .9em; }
  pre { background: #fdeef3; padding: 1rem; border-radius: .6rem; overflow-x: auto; }
  pre code { background: none; padding: 0; }
  hr { border: none; border-top: 1px solid #f3cdd9; margin: 2rem 0; }
  a { color: #b04a6a; }
  ul, ol { padding-left: 1.4rem; }
</style>
</head>
<body>
<h1>%s</h1>
%s</body>
</html>
`

func htmlBlock(n pmNode) string {
	switch n.Type {
	case "heading":
		tag := fmt.Sprintf("h%d", clampHeading(n.level()))
		return fmt.Sprintf("<%s>%s</%s>\n", tag, htmlInline(n.Content), tag)
	case "paragraph":
		inner := htmlInline(n.Content)
		if inner == "" {
			return "<p><br></p>\n"
		}
		return "<p>" + inner + "</p>\n"
	case "blockquote":
		var b strings.Builder
		for _, c := range n.Content {
			b.WriteString(htmlBlock(c))
		}
		return "<blockquote>" + b.String() + "</blockquote>\n"
	case "codeBlock":
		return "<pre><code>" + htmlEscape(textContent(n)) + "</code></pre>\n"
	case "horizontalRule":
		return "<hr>\n"
	case "image":
		alt := htmlEscape(n.attrStr("alt"))
		return fmt.Sprintf("<p><img src=\"%s\" alt=\"%s\"></p>\n", htmlEscape(n.attrStr("src")), alt)
	case "table":
		return htmlTable(n)
	case "bulletList", "orderedList":
		tag := "ul"
		if n.Type == "orderedList" {
			tag = "ol"
		}
		var b strings.Builder
		b.WriteString("<" + tag + ">\n")
		for _, item := range n.Content {
			var inner strings.Builder
			for _, c := range item.Content {
				// Unwrap a lone paragraph so list items aren't double-spaced.
				if c.Type == "paragraph" {
					inner.WriteString(htmlInline(c.Content))
				} else {
					inner.WriteString(htmlBlock(c))
				}
			}
			b.WriteString("<li>" + inner.String() + "</li>\n")
		}
		b.WriteString("</" + tag + ">\n")
		return b.String()
	default:
		if len(n.Content) > 0 {
			return "<p>" + htmlInline(n.Content) + "</p>\n"
		}
		return ""
	}
}

// htmlTable renders a table node as an HTML <table>. tableHeader cells become
// <th>, tableCell cells become <td>; each cell's block children are flattened
// to inline HTML.
func htmlTable(n pmNode) string {
	var b strings.Builder
	b.WriteString("<table>\n")
	for _, row := range n.Content {
		if row.Type != "tableRow" {
			continue
		}
		b.WriteString("<tr>")
		for _, cell := range row.Content {
			tag := "td"
			if cell.Type == "tableHeader" {
				tag = "th"
			}
			var inner strings.Builder
			for _, c := range cell.Content {
				if c.Type == "paragraph" {
					inner.WriteString(htmlInline(c.Content))
				} else {
					inner.WriteString(htmlBlock(c))
				}
			}
			b.WriteString("<" + tag + ">" + inner.String() + "</" + tag + ">")
		}
		b.WriteString("</tr>\n")
	}
	b.WriteString("</table>\n")
	return b.String()
}

func htmlInline(nodes []pmNode) string {
	var b strings.Builder
	for _, n := range nodes {
		switch n.Type {
		case "text":
			b.WriteString(applyHTMLMarks(n))
		case "hardBreak":
			b.WriteString("<br>")
		default:
			b.WriteString(htmlInline(n.Content))
		}
	}
	return b.String()
}

func applyHTMLMarks(n pmNode) string {
	t := htmlEscape(n.Text)
	if n.hasMark("code") {
		return "<code>" + t + "</code>"
	}
	if n.hasMark("bold") {
		t = "<strong>" + t + "</strong>"
	}
	if n.hasMark("italic") {
		t = "<em>" + t + "</em>"
	}
	if n.hasMark("underline") {
		t = "<u>" + t + "</u>"
	}
	if n.hasMark("strike") {
		t = "<s>" + t + "</s>"
	}
	if n.hasMark("highlight") {
		t = "<mark>" + t + "</mark>"
	}
	if href := n.markAttr("link", "href"); href != "" {
		t = fmt.Sprintf("<a href=\"%s\">%s</a>", htmlEscape(href), t)
	}
	return t
}

func clampHeading(level int) int {
	if level < 1 {
		return 1
	}
	if level > 6 {
		return 6
	}
	return level
}

// --- DOCX -------------------------------------------------------------------

// renderDocx builds a minimal but valid .docx (Office Open XML) in pure Go: a
// zip of the few XML parts Word needs. Bold/italic/underline/strike map to run
// properties; headings use Word's built-in styles; lists are rendered with a
// bullet/number prefix (no numbering.xml dependency). CJK renders with the
// reader's own fonts, so no font embedding is required.
func renderDocx(doc db.Document) ([]byte, error) {
	root := parseDoc(doc)

	var body strings.Builder
	body.WriteString(docxHeading(doc.Title, 1))
	for _, child := range root.Content {
		body.WriteString(docxBlock(child))
	}

	document := xmlHeader + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
		`<w:body>` + body.String() +
		`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>` +
		`</w:body></w:document>`

	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	parts := []struct{ name, content string }{
		{"[Content_Types].xml", docxContentTypes},
		{"_rels/.rels", docxRootRels},
		{"word/_rels/document.xml.rels", docxDocRels},
		{"word/document.xml", document},
	}
	for _, p := range parts {
		fw, err := zw.Create(p.name)
		if err != nil {
			return nil, err
		}
		if _, err := fw.Write([]byte(p.content)); err != nil {
			return nil, err
		}
	}
	if err := zw.Close(); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

const (
	xmlHeader        = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n"
	docxContentTypes = xmlHeader + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
		`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
		`<Default Extension="xml" ContentType="application/xml"/>` +
		`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>` +
		`</Types>`
	docxRootRels = xmlHeader + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
		`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>` +
		`</Relationships>`
	docxDocRels = xmlHeader + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
)

func docxBlock(n pmNode) string {
	switch n.Type {
	case "heading":
		return docxHeading(textContent(n), n.level())
	case "paragraph":
		return docxPara(n, "")
	case "blockquote":
		var b strings.Builder
		for _, c := range n.Content {
			b.WriteString(docxPara(c, "Quote"))
		}
		return b.String()
	case "codeBlock":
		// One paragraph per line preserves layout without a code style.
		var b strings.Builder
		for _, line := range strings.Split(textContent(n), "\n") {
			b.WriteString(`<w:p><w:r><w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/></w:rPr>` +
				`<w:t xml:space="preserve">` + xmlEscape(line) + `</w:t></w:r></w:p>`)
		}
		return b.String()
	case "horizontalRule":
		return `<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr></w:pPr></w:p>`
	case "image":
		// Image bytes aren't embedded (that needs media parts); leave a labeled
		// placeholder so the reader knows an image belonged here.
		label := n.attrStr("alt")
		if label == "" {
			label = "image"
		}
		return docxPara(pmNode{Content: []pmNode{{Type: "text", Text: "[" + label + "]", Marks: []pmMark{{Type: "italic"}}}}}, "")
	case "table":
		return docxTable(n)
	case "bulletList", "orderedList":
		var b strings.Builder
		for i, item := range n.Content {
			prefix := "•  "
			if n.Type == "orderedList" {
				prefix = fmt.Sprintf("%d.  ", i+1)
			}
			for _, c := range item.Content {
				b.WriteString(docxPara(c, "", prefix))
			}
		}
		return b.String()
	default:
		if len(n.Content) > 0 {
			return docxPara(n, "")
		}
		return ""
	}
}

// docxTable renders a table node as a Word table (w:tbl) with single-line
// borders. Header cells get a shaded background; every cell's block children are
// rendered as paragraphs inside the cell.
func docxTable(n pmNode) string {
	var b strings.Builder
	b.WriteString(`<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="0" w:type="auto"/>` +
		`<w:tblBorders>` +
		`<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
		`<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
		`<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
		`<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
		`<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
		`<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
		`</w:tblBorders></w:tblPr>`)
	for _, row := range n.Content {
		if row.Type != "tableRow" {
			continue
		}
		b.WriteString("<w:tr>")
		for _, cell := range row.Content {
			b.WriteString("<w:tc><w:tcPr>")
			if cell.Type == "tableHeader" {
				b.WriteString(`<w:shd w:val="clear" w:color="auto" w:fill="FFF0F5"/>`)
			}
			b.WriteString("</w:tcPr>")
			// A cell must contain at least one paragraph to be valid.
			if len(cell.Content) == 0 {
				b.WriteString("<w:p/>")
			}
			for _, c := range cell.Content {
				b.WriteString(docxBlock(c))
			}
			b.WriteString("</w:tc>")
		}
		b.WriteString("</w:tr>")
	}
	b.WriteString("</w:tbl>")
	// Word needs a paragraph after a table; otherwise consecutive tables merge.
	b.WriteString("<w:p/>")
	return b.String()
}

// docxPara renders a block's inline children as a Word paragraph. An optional
// style name (e.g. "Quote") and an optional literal text prefix (for list
// markers) may be supplied.
func docxPara(n pmNode, style string, prefix ...string) string {
	var b strings.Builder
	b.WriteString("<w:p>")
	if style != "" {
		b.WriteString(`<w:pPr><w:pStyle w:val="` + style + `"/></w:pPr>`)
	}
	if len(prefix) > 0 && prefix[0] != "" {
		b.WriteString(docxRun(pmNode{Type: "text", Text: prefix[0]}))
	}
	b.WriteString(docxInline(n.Content))
	b.WriteString("</w:p>")
	return b.String()
}

func docxHeading(text string, level int) string {
	return `<w:p><w:pPr><w:pStyle w:val="Heading` + fmt.Sprintf("%d", clampHeading(level)) + `"/></w:pPr>` +
		docxRun(pmNode{Type: "text", Text: text}) + `</w:p>`
}

func docxInline(nodes []pmNode) string {
	var b strings.Builder
	for _, n := range nodes {
		switch n.Type {
		case "text":
			b.WriteString(docxRun(n))
		case "hardBreak":
			b.WriteString(`<w:r><w:br/></w:r>`)
		default:
			b.WriteString(docxInline(n.Content))
		}
	}
	return b.String()
}

func docxRun(n pmNode) string {
	var props strings.Builder
	if n.hasMark("bold") {
		props.WriteString("<w:b/>")
	}
	if n.hasMark("italic") {
		props.WriteString("<w:i/>")
	}
	if n.hasMark("underline") {
		props.WriteString(`<w:u w:val="single"/>`)
	}
	if n.hasMark("strike") {
		props.WriteString("<w:strike/>")
	}
	if n.hasMark("highlight") {
		// Word's text highlight only supports a fixed palette of named colors.
		props.WriteString(`<w:highlight w:val="yellow"/>`)
	}
	if n.markAttr("link", "href") != "" {
		// Without hyperlink relationships, style links as blue underlined text so
		// they at least read as links (the URL itself is preserved in md/html).
		props.WriteString(`<w:color w:val="2563EB"/><w:u w:val="single"/>`)
	}
	rpr := ""
	if props.Len() > 0 {
		rpr = "<w:rPr>" + props.String() + "</w:rPr>"
	}
	return `<w:r>` + rpr + `<w:t xml:space="preserve">` + xmlEscape(n.Text) + `</w:t></w:r>`
}

// --- small helpers ----------------------------------------------------------

func htmlEscape(s string) string {
	r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&#39;")
	return r.Replace(s)
}

func xmlEscape(s string) string {
	r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
	return r.Replace(s)
}

// sanitizeFilename makes a title safe as a download filename, preserving CJK and
// most letters while dropping path separators and control characters.
func sanitizeFilename(title string) string {
	title = strings.TrimSpace(title)
	if title == "" {
		return "untitled"
	}
	var b strings.Builder
	for _, r := range title {
		switch {
		case r < 0x20, r == '/', r == '\\', r == ':', r == '*', r == '?', r == '"', r == '<', r == '>', r == '|':
			b.WriteRune('-')
		default:
			b.WriteRune(r)
		}
	}
	out := strings.TrimSpace(b.String())
	if out == "" {
		return "untitled"
	}
	return out
}

// urlEscapeFilename percent-encodes a filename for the RFC 5987 filename*=
// Content-Disposition form, which carries UTF-8 (CJK titles) safely.
func urlEscapeFilename(s string) string {
	var b strings.Builder
	for _, c := range []byte(s) {
		if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
			c == '-' || c == '_' || c == '.' || c == '~' {
			b.WriteByte(c)
		} else {
			fmt.Fprintf(&b, "%%%02X", c)
		}
	}
	return b.String()
}
