package sync

import (
	"archive/zip"
	"encoding/xml"
	"fmt"
	"io"
	"log"
	"net/url"
	"path"
	"regexp"
	"strconv"
	"strings"
	"unicode/utf8"

	"golang.org/x/net/html"
)

type CFIConverter struct {
	epubPath string
	cache    *spineCache
}

type spineItem struct {
	href      string
	mediaType string
}

type spineCache struct {
	items    []spineItem
	opfDir   string
	docCache map[string]*html.Node
}

func NewCFIConverter(epubPath string) *CFIConverter {
	return &CFIConverter{epubPath: epubPath}
}

func (c *CFIConverter) loadSpine() (*spineCache, error) {
	if c.cache != nil {
		return c.cache, nil
	}

	r, err := zip.OpenReader(c.epubPath)
	if err != nil {
		return nil, fmt.Errorf("open epub: %w", err)
	}
	defer r.Close()

	containerData, err := readZipFile(&r.Reader, "META-INF/container.xml")
	if err != nil {
		return nil, fmt.Errorf("read container.xml: %w", err)
	}

	opfPath, err := extractOPFPath(containerData)
	if err != nil {
		return nil, fmt.Errorf("extract opf path: %w", err)
	}

	opfData, err := readZipFile(&r.Reader, opfPath)
	if err != nil {
		return nil, fmt.Errorf("read opf: %w", err)
	}

	items, err := parseOPFSpine(opfData)
	if err != nil {
		return nil, fmt.Errorf("parse opf spine: %w", err)
	}

	opfDir := path.Dir(opfPath)
	if opfDir == "." {
		opfDir = ""
	}

	c.cache = &spineCache{
		items:    items,
		opfDir:   opfDir,
		docCache: make(map[string]*html.Node),
	}
	return c.cache, nil
}

func (c *CFIConverter) getContentDoc(fragmentIndex int) (*html.Node, string, error) {
	spine, err := c.loadSpine()
	if err != nil {
		return nil, "", err
	}

	spineIndex := fragmentIndex - 1
	if spineIndex < 0 || spineIndex >= len(spine.items) {
		return nil, "", fmt.Errorf("fragment index %d out of range (spine has %d items)", fragmentIndex, len(spine.items))
	}

	item := spine.items[spineIndex]
	href := item.href

	if cached, ok := spine.docCache[href]; ok {
		return cached, href, nil
	}

	r, err := zip.OpenReader(c.epubPath)
	if err != nil {
		return nil, "", fmt.Errorf("open epub: %w", err)
	}
	defer r.Close()

	zipPath := resolveZipPath(spine.opfDir, href)
	data, err := readZipFile(&r.Reader, zipPath)
	if err != nil {
		decoded, _ := url.QueryUnescape(zipPath)
		if decoded != zipPath {
			data, err = readZipFile(&r.Reader, decoded)
		}
		if err != nil {
			return nil, "", fmt.Errorf("read content doc %s: %w", href, err)
		}
	}

	doc, err := html.Parse(strings.NewReader(preprocessXHTML(string(data))))
	if err != nil {
		return nil, "", fmt.Errorf("parse html: %w", err)
	}

	spine.docCache[href] = doc
	return doc, href, nil
}

type CREXPointer struct {
	FragmentIndex int
	ElementPath   []pathStep
	CharOffset    int
}

type pathStep struct {
	tag    string
	index  int
	isText bool
}

type CREFragmentID struct {
	SpineIndex int
	Anchor     string
}

var creFragmentIDRe = regexp.MustCompile(`^#_doc_fragment_(\d+)(?:[_ ](.*))?$`)

func ParseCREFragmentID(s string) (*CREFragmentID, error) {
	m := creFragmentIDRe.FindStringSubmatch(s)
	if m == nil {
		return nil, fmt.Errorf("not a CREngine fragment ID: %s", s)
	}
	idx, err := strconv.Atoi(m[1])
	if err != nil {
		return nil, fmt.Errorf("parse fragment spine index: %w", err)
	}
	return &CREFragmentID{
		SpineIndex: idx,
		Anchor:     strings.TrimSpace(m[2]),
	}, nil
}

var creXPointerRe = regexp.MustCompile(`^/body/DocFragment\[(\d+)\](.*)`)

func ParseCREXPointer(xp string) (*CREXPointer, error) {
	m := creXPointerRe.FindStringSubmatch(xp)
	if m == nil {
		return nil, fmt.Errorf("not a CREngine XPointer: %s", xp)
	}

	fragIdx, err := strconv.Atoi(m[1])
	if err != nil {
		return nil, fmt.Errorf("parse fragment index: %w", err)
	}

	result := &CREXPointer{
		FragmentIndex: fragIdx,
		CharOffset:    0,
	}

	rest := m[2]
	if rest == "" {
		return result, nil
	}

	if strings.HasPrefix(rest, "/body") {
		rest = strings.TrimPrefix(rest, "/body")
		if rest == "" {
			return result, nil
		}
	}
	rest = strings.TrimPrefix(rest, "/")

	if rest == "" {
		return result, nil
	}

	parts := strings.Split(rest, "/")
	for _, part := range parts {
		if part == "" {
			continue
		}
		if strings.HasPrefix(part, "text()") {
			rest := part[6:]
			offset := 0
			if rest != "" {
				if idx := strings.Index(rest, "."); idx >= 0 {
					offset, _ = strconv.Atoi(rest[idx+1:])
				}
			}
			result.CharOffset = offset
			continue
		}

		tag, idx := parseElementPart(part)
		result.ElementPath = append(result.ElementPath, pathStep{tag: tag, index: idx})
	}

	return result, nil
}

func parseElementPart(part string) (string, int) {
	idxStr := ""
	if i := strings.Index(part, "["); i >= 0 {
		idxStr = part[i+1:]
		part = part[:i]
		if strings.HasSuffix(idxStr, "]") {
			idxStr = idxStr[:len(idxStr)-1]
		}
	}
	idx := 1
	if idxStr != "" {
		if v, err := strconv.Atoi(idxStr); err == nil {
			idx = v
		}
	}
	return part, idx
}

type ConversionResult struct {
	EPUBCFI    string
	Href       string
	Percentage float64
	Precision  string
}

func (c *CFIConverter) ConvertCREToStandard(xpointer string, storedPercentage float64, contextText string) (*ConversionResult, error) {
	if IsCREFragmentID(xpointer) {
		return c.convertFragmentID(xpointer, storedPercentage)
	}

	xp, err := ParseCREXPointer(xpointer)
	if err != nil {
		return nil, err
	}

	spine, err := c.loadSpine()
	if err != nil {
		return &ConversionResult{
			Percentage: storedPercentage,
			Precision:  "percentage",
		}, nil
	}

	doc, href, docErr := c.getContentDoc(xp.FragmentIndex)
	if docErr != nil {
		return &ConversionResult{
			Percentage: storedPercentage,
			Precision:  "percentage",
		}, nil
	}
	body := findBody(doc)
	if body == nil {
		return &ConversionResult{
			Percentage: storedPercentage,
			Precision:  "percentage",
		}, nil
	}

	if contextText != "" {
		result := c.convertByTextSearch(body, xp, spine, href, storedPercentage, contextText)
		if result != nil {
			return result, nil
		}
	}

	return c.convertByPercentageOffset(body, xp, spine, href, storedPercentage)
}

func (c *CFIConverter) convertFragmentID(s string, storedPercentage float64) (*ConversionResult, error) {
	frag, err := ParseCREFragmentID(s)
	if err != nil {
		return &ConversionResult{
			Percentage: storedPercentage,
			Precision:  "percentage",
		}, nil
	}

	spine, err := c.loadSpine()
	if err != nil {
		return &ConversionResult{
			Percentage: storedPercentage,
			Precision:  "percentage",
		}, nil
	}

	if frag.SpineIndex < 0 || frag.SpineIndex >= len(spine.items) {
		return &ConversionResult{
			Percentage: storedPercentage,
			Precision:  "percentage",
		}, nil
	}

	href := spine.items[frag.SpineIndex].href
	if frag.Anchor != "" {
		idHref := href + "#" + frag.Anchor
		return &ConversionResult{
			Href:       idHref,
			Percentage: storedPercentage,
			Precision:  "element",
		}, nil
	}

	return &ConversionResult{
		Href:       href,
		Percentage: storedPercentage,
		Precision:  "section",
	}, nil
}

func (c *CFIConverter) convertByTextSearch(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64, contextText string) *ConversionResult {
	normalizedCtx := normalizeWhitespace(contextText)
	if normalizedCtx == "" {
		return nil
	}

	match, matchOffset := findTextInNode(body, normalizedCtx)
	if match == nil {
		log.Printf("Bookhoard: text search no match for %q in %s", normalizedCtx, href)
		return nil
	}

	spineIndex := xp.FragmentIndex - 1
	cfi, err := buildCFI(spineIndex, match, matchOffset)
	if err != nil || cfi == "" {
		log.Printf("Bookhoard: text search found match but buildCFI failed: %v", err)
		return nil
	}

	log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", normalizedCtx, cfi)
	return &ConversionResult{
		EPUBCFI:    cfi,
		Href:       href,
		Percentage: storedPercentage,
		Precision:  "exact",
	}
}

func findTextInNode(root *html.Node, searchText string) (*html.Node, int) {
	normalizedSearch := normalizeWhitespace(searchText)
	if normalizedSearch == "" {
		return nil, 0
	}

	words := strings.Fields(normalizedSearch)
	quoted := make([]string, len(words))
	for i, w := range words {
		quoted[i] = regexp.QuoteMeta(w)
	}
	pattern := strings.Join(quoted, `\s+`)
	re, err := regexp.Compile(pattern)
	if err != nil {
		return nil, 0
	}

	var found *html.Node
	foundRuneOffset := 0

	var walk func(*html.Node) bool
	walk = func(n *html.Node) bool {
		if n.Type == html.TextNode {
			loc := re.FindStringIndex(n.Data)
			if loc != nil {
				found = n
				foundRuneOffset = utf8.RuneCountInString(n.Data[:loc[0]])
				return true
			}
		}
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			if walk(c) {
				return true
			}
		}
		return false
	}

	walk(root)
	if found != nil {
		return found, foundRuneOffset
	}

	return findTextAcrossInlineElements(root, re)
}

func findTextAcrossInlineElements(root *html.Node, re *regexp.Regexp) (*html.Node, int) {
	var blockElements []*html.Node
	collectBlockElements(root, &blockElements)

	for _, block := range blockElements {
		segments := collectInlineText(block)
		if len(segments) == 0 {
			continue
		}

		var allRunes []rune
		for _, seg := range segments {
			allRunes = append(allRunes, seg.runes...)
		}

		flattened := string(allRunes)
		loc := re.FindStringIndex(flattened)
		if loc == nil {
			continue
		}

		matchStart := utf8.RuneCountInString(flattened[:loc[0]])
		charPos := 0
		for _, seg := range segments {
			if charPos+len(seg.runes) > matchStart {
				localOffset := matchStart - charPos
				return seg.node, localOffset
			}
			charPos += len(seg.runes)
		}
	}

	return nil, 0
}

func collectBlockElements(n *html.Node, result *[]*html.Node) {
	if n.Type == html.ElementNode && !inlineFormattingElements[n.Data] {
		hasText := false
		var checkText func(*html.Node)
		checkText = func(c *html.Node) {
			if c.Type == html.TextNode && strings.TrimSpace(c.Data) != "" {
				hasText = true
				return
			}
			for child := c.FirstChild; child != nil; child = child.NextSibling {
				checkText(child)
			}
		}
		checkText(n)
		if hasText {
			*result = append(*result, n)
		}
	}
	for c := n.FirstChild; c != nil; c = c.NextSibling {
		collectBlockElements(c, result)
	}
}

func normalizeWhitespace(s string) string {
	s = strings.Join(strings.Fields(s), " ")
	return strings.TrimSpace(s)
}

func (c *CFIConverter) convertByPercentageOffset(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64) (*ConversionResult, error) {
	totalBookChars := 0
	charCounts := make([]int, len(spine.items))
	for i := range spine.items {
		doc, _, docErr := c.getContentDoc(i + 1)
		if docErr != nil {
			continue
		}
		b := findBody(doc)
		if b != nil {
			charCounts[i] = countTextChars(b)
			totalBookChars += charCounts[i]
		}
	}

	if totalBookChars <= 0 {
		return &ConversionResult{
			Percentage: storedPercentage,
			Precision:  "percentage",
		}, nil
	}

	bookWideOffset := int(storedPercentage * float64(totalBookChars))
	if bookWideOffset >= totalBookChars {
		bookWideOffset = totalBookChars - 1
	}

	charsBefore := 0
	for i := 0; i < xp.FragmentIndex-1 && i < len(spine.items); i++ {
		charsBefore += charCounts[i]
	}

	localOffset := bookWideOffset - charsBefore
	totalChars := countTextChars(body)
	if localOffset < 0 {
		localOffset = 0
	}
	if localOffset >= totalChars {
		localOffset = totalChars - 1
	}

	target, foundOffset := findNodeAtCharOffset(body, localOffset)

	if target != nil {
		spineIndex := xp.FragmentIndex - 1
		cfi, err := buildCFI(spineIndex, target, localOffset-foundOffset)
		if err == nil && cfi != "" {
			return &ConversionResult{
				EPUBCFI:    cfi,
				Href:       href,
				Percentage: storedPercentage,
				Precision:  "exact",
			}, nil
		}
	}

	idHref := findNearestIDHref(body, target, href)
	if idHref != "" {
		return &ConversionResult{
			Href:       idHref,
			Percentage: storedPercentage,
			Precision:  "element",
		}, nil
	}

	return &ConversionResult{
		Href:       href,
		Percentage: storedPercentage,
		Precision:  "section",
	}, nil
}

func findNodeAtCharOffset(root *html.Node, targetOffset int) (*html.Node, int) {
	currentOffset := 0
	var found *html.Node

	var walk func(*html.Node) bool
	walk = func(n *html.Node) bool {
		if n.Type == html.TextNode {
			textLen := utf8.RuneCountInString(n.Data)
			if currentOffset+textLen > targetOffset {
				found = n
				return true
			}
			currentOffset += textLen
			return false
		}
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			if walk(c) {
				return true
			}
		}
		return false
	}

	walk(root)
	return found, currentOffset
}

func (c *CFIConverter) computePercentage(xp *CREXPointer, target *html.Node, remainingOffset int) float64 {
	spine, err := c.loadSpine()
	if err != nil {
		return -1
	}

	charOffset := 0
	for i := 1; i < xp.FragmentIndex; i++ {
		doc, _, err := c.getContentDoc(i)
		if err != nil {
			continue
		}
		body := findBody(doc)
		if body != nil {
			charOffset += countTextChars(body)
		}
	}

	charOffset += countTextCharsBefore(target)
	charOffset += remainingOffset

	totalChars := int64(0)
	for i := range spine.items {
		doc, _, err := c.getContentDoc(i + 1)
		if err != nil {
			continue
		}
		body := findBody(doc)
		if body != nil {
			totalChars += int64(countTextChars(body))
		}
	}

	if totalChars <= 0 {
		return -1
	}
	return float64(charOffset) / float64(totalChars)
}

type indexedNode struct {
	node      *html.Node
	textChunk []*html.Node
	virtual   string
	isNull    bool
}

func (n indexedNode) isElement() bool {
	return n.node != nil && n.node.Type == html.ElementNode
}

func (n indexedNode) isTextChunk() bool {
	return len(n.textChunk) > 0
}

func indexChildNodes(parent *html.Node) []indexedNode {
	var children []*html.Node
	for c := parent.FirstChild; c != nil; c = c.NextSibling {
		if c.Type == html.TextNode || c.Type == html.ElementNode {
			children = append(children, c)
		}
	}

	var nodes []indexedNode
	for _, child := range children {
		if len(nodes) == 0 {
			if child.Type == html.TextNode {
				nodes = append(nodes, indexedNode{textChunk: []*html.Node{child}})
			} else {
				nodes = append(nodes, indexedNode{node: child})
			}
			continue
		}

		last := nodes[len(nodes)-1]
		if child.Type == html.TextNode {
			if last.isTextChunk() {
				nodes[len(nodes)-1].textChunk = append(last.textChunk, child)
			} else {
				nodes = append(nodes, indexedNode{textChunk: []*html.Node{child}})
			}
		} else {
			if last.isElement() {
				nodes = append(nodes, indexedNode{isNull: true}, indexedNode{node: child})
			} else {
				nodes = append(nodes, indexedNode{node: child})
			}
		}
	}

	if len(nodes) > 0 && nodes[0].isElement() {
		nodes = append([]indexedNode{{virtual: "first"}}, nodes...)
	}
	if len(nodes) > 0 && nodes[len(nodes)-1].isElement() {
		nodes = append(nodes, indexedNode{virtual: "last"})
	}
	nodes = append([]indexedNode{{virtual: "before"}}, nodes...)
	nodes = append(nodes, indexedNode{virtual: "after"})

	return nodes
}

func findTextChunkIndex(parent *html.Node, textNode *html.Node) (int, int) {
	indexed := indexChildNodes(parent)
	for i, node := range indexed {
		if node.isTextChunk() {
			for j, tn := range node.textChunk {
				if tn == textNode {
					chunkOffset := 0
					for k := 0; k < j; k++ {
						chunkOffset += utf8.RuneCountInString(node.textChunk[k].Data)
					}
					return i, chunkOffset
				}
			}
		}
	}
	return -1, 0
}

func findElementCFIIndex(parent *html.Node, element *html.Node) int {
	indexed := indexChildNodes(parent)
	for i, node := range indexed {
		if node.isElement() && node.node == element {
			return i
		}
	}
	return -1
}

func escapeCFI(s string) string {
	r := strings.NewReplacer(
		"^", "^^",
		"[", "^[",
		"]", "^]",
		"(", "^(",
		")", "^)",
		",", "^,",
		";", "^;",
		"=", "^=",
	)
	return r.Replace(s)
}

func buildCFI(spineIndex int, textNode *html.Node, charOffset int) (string, error) {
	if textNode == nil || textNode.Type != html.TextNode {
		return "", fmt.Errorf("buildCFI requires a text node")
	}

	parent := textNode.Parent
	if parent == nil {
		return "", fmt.Errorf("text node has no parent")
	}

	chunkIdx, chunkOffset := findTextChunkIndex(parent, textNode)
	if chunkIdx == -1 {
		return "", fmt.Errorf("text node not found in parent's indexed children")
	}

	totalOffset := chunkOffset + charOffset

	var parts []string
	parts = append(parts, fmt.Sprintf("/%d:%d", chunkIdx, totalOffset))

	current := parent
	for current != nil {
		if current.Type != html.ElementNode {
			current = current.Parent
			continue
		}

		p := current.Parent
		if p == nil {
			break
		}

		elemIdx := findElementCFIIndex(p, current)
		if elemIdx == -1 {
			return "", fmt.Errorf("element not found in parent's indexed children")
		}

		id := getAttr(current, "id")
		step := fmt.Sprintf("/%d", elemIdx)
		if id != "" {
			step = fmt.Sprintf("/%d[%s]", elemIdx, escapeCFI(id))
		}
		parts = append([]string{step}, parts...)

		body := findBodyFromNode(p)
		if p == body || body == nil {
			break
		}
		current = p
	}

	parts = append([]string{"/4"}, parts...)

	spineStep := (spineIndex + 1) * 2
	localPath := strings.Join(parts, "")

	return fmt.Sprintf("epubcfi(/6/%d!%s)", spineStep, localPath), nil
}

func findNearestIDHref(body *html.Node, target *html.Node, baseHref string) string {
	if target == nil {
		return ""
	}

	for current := target; current != nil; current = current.Parent {
		if current.Type != html.ElementNode {
			continue
		}
		if id := getAttr(current, "id"); id != "" {
			if strings.Contains(baseHref, "#") {
				return baseHref + id
			}
			return baseHref + "#" + id
		}
		bodyNode := findBodyFromNode(current)
		if current == bodyNode || bodyNode == nil {
			break
		}
	}

	return baseHref
}

func findHTMLElement(doc *html.Node) *html.Node {
	var find func(*html.Node) *html.Node
	find = func(n *html.Node) *html.Node {
		if n.Type == html.ElementNode && strings.EqualFold(n.Data, "html") {
			return n
		}
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			if found := find(c); found != nil {
				return found
			}
		}
		return nil
	}
	return find(doc)
}

func findBody(doc *html.Node) *html.Node {
	var find func(*html.Node) *html.Node
	find = func(n *html.Node) *html.Node {
		if n.Type == html.ElementNode && strings.EqualFold(n.Data, "body") {
			return n
		}
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			if found := find(c); found != nil {
				return found
			}
		}
		return nil
	}
	return find(doc)
}

func findBodyFromNode(n *html.Node) *html.Node {
	for n != nil {
		if n.Type == html.ElementNode && strings.EqualFold(n.Data, "body") {
			return n
		}
		n = n.Parent
	}
	return nil
}

func getAttr(n *html.Node, key string) string {
	for _, a := range n.Attr {
		if strings.EqualFold(a.Key, key) {
			return a.Val
		}
	}
	return ""
}

func countTextChars(n *html.Node) int {
	count := 0
	if n.Type == html.TextNode {
		count += utf8.RuneCountInString(n.Data)
	}
	for c := n.FirstChild; c != nil; c = c.NextSibling {
		count += countTextChars(c)
	}
	return count
}

func countTextCharsBefore(target *html.Node) int {
	count := 0
	for c := target; c != nil; c = c.PrevSibling {
		count += countTextChars(c)
	}
	if target.Parent != nil {
		count += countTextCharsBefore(target.Parent)
	}
	return count
}

func resolveZipPath(opfDir, href string) string {
	if opfDir == "" {
		return href
	}
	return path.Join(opfDir, href)
}

func readZipFile(zr *zip.Reader, name string) ([]byte, error) {
	for _, f := range zr.File {
		if f.Name == name {
			rc, err := f.Open()
			if err != nil {
				return nil, err
			}
			defer rc.Close()
			return io.ReadAll(rc)
		}
	}
	return nil, fmt.Errorf("file not found: %s", name)
}

type opfContainer struct {
	XMLName   xml.Name   `xml:"container"`
	RootFiles []opfRoot  `xml:"rootfiles>rootfile"`
}

type opfRoot struct {
	FullPath string `xml:"full-path,attr"`
}

func extractOPFPath(data []byte) (string, error) {
	var c opfContainer
	if err := xml.Unmarshal(data, &c); err != nil {
		return "", err
	}
	for _, rf := range c.RootFiles {
		if rf.FullPath != "" {
			return rf.FullPath, nil
		}
	}
	return "", fmt.Errorf("no rootfile found in container.xml")
}

type xmlPackage struct {
	XMLName xml.Name `xml:"package"`
	Spine   xmlSpine `xml:"spine"`
	Manifest xmlManifest `xml:"manifest"`
}

type xmlSpine struct {
	ItemRefs []xmlItemRef `xml:"itemref"`
}

type xmlItemRef struct {
	IDRef string `xml:"idref,attr"`
}

type xmlManifest struct {
	Items []xmlItem `xml:"item"`
}

type xmlItem struct {
	ID        string `xml:"id,attr"`
	Href      string `xml:"href,attr"`
	MediaType string `xml:"media-type,attr"`
}

func parseOPFSpine(data []byte) ([]spineItem, error) {
	var pkg xmlPackage
	if err := xml.Unmarshal(data, &pkg); err != nil {
		return nil, err
	}

	manifest := make(map[string]spineItem)
	for _, item := range pkg.Manifest.Items {
		manifest[item.ID] = spineItem{
			href:      item.Href,
			mediaType: item.MediaType,
		}
	}

	var items []spineItem
	for _, ref := range pkg.Spine.ItemRefs {
		if item, ok := manifest[ref.IDRef]; ok {
			items = append(items, item)
		}
	}

	return items, nil
}

func IsCREXPointer(s string) bool {
	return strings.HasPrefix(s, "/body/DocFragment[") || strings.HasPrefix(s, "#_doc_fragment_")
}

func IsCREFragmentID(s string) bool {
	return strings.HasPrefix(s, "#_doc_fragment_")
}

func IsStandardEPUBCFI(s string) bool {
	return strings.HasPrefix(s, "epubcfi(") && strings.Contains(s, ")")
}

var voidElements = map[string]bool{
	"area": true, "base": true, "br": true, "col": true, "embed": true,
	"hr": true, "img": true, "input": true, "link": true, "meta": true,
	"param": true, "source": true, "track": true, "wbr": true,
}

var inlineFormattingElements = map[string]bool{
	"a": true, "abbr": true, "b": true, "bdo": true, "cite": true,
	"code": true, "del": true, "dfn": true, "em": true, "i": true,
	"ins": true, "kbd": true, "mark": true, "q": true, "samp": true,
	"small": true, "span": true, "strong": true, "sub": true, "sup": true,
	"u": true, "var": true,
}

func isInlineFormatting(n *html.Node) bool {
	return n != nil && n.Type == html.ElementNode && inlineFormattingElements[n.Data]
}

type textSegment struct {
	node   *html.Node
	runes  []rune
	offset int
}

func collectInlineText(blockParent *html.Node) []textSegment {
	var segments []textSegment
	var walk func(*html.Node)
	walk = func(n *html.Node) {
		if n.Type == html.TextNode {
			runes := []rune(n.Data)
			segments = append(segments, textSegment{node: n, runes: runes})
		} else if isInlineFormatting(n) {
			for c := n.FirstChild; c != nil; c = c.NextSibling {
				walk(c)
			}
		}
	}
	for c := blockParent.FirstChild; c != nil; c = c.NextSibling {
		walk(c)
	}
	return segments
}

func findBlockParent(textNode *html.Node) *html.Node {
	current := textNode.Parent
	for current != nil {
		if current.Type == html.ElementNode && !inlineFormattingElements[current.Data] {
			return current
		}
		current = current.Parent
	}
	return nil
}

var selfClosingRe = regexp.MustCompile(`<([a-zA-Z][a-zA-Z0-9]*)([^>]*?)/\s*>`)

func preprocessXHTML(input string) string {
	return selfClosingRe.ReplaceAllStringFunc(input, func(match string) string {
		sub := selfClosingRe.FindStringSubmatch(match)
		if len(sub) < 3 {
			return match
		}
		tag := strings.ToLower(sub[1])
		if voidElements[tag] {
			return match
		}
		return fmt.Sprintf("<%s%s></%s>", sub[1], sub[2], sub[1])
	})
}

type cfiStep struct {
	Index    int
	ID       string
	Offset   int
	HasOffset bool
}

var cfiStepRe = regexp.MustCompile(`/(\d+)(?:\[([^\]]*)\])?(?::(\d+))?`)

func parseCFISteps(s string) []cfiStep {
	var steps []cfiStep
	matches := cfiStepRe.FindAllStringSubmatch(s, -1)
	for _, m := range matches {
		idx, _ := strconv.Atoi(m[1])
		step := cfiStep{Index: idx}
		if m[2] != "" {
			step.ID = m[2]
		}
		if m[3] != "" {
			step.Offset, _ = strconv.Atoi(m[3])
			step.HasOffset = true
		}
		steps = append(steps, step)
	}
	return steps
}

func parseEPUBCFI(cfi string) (spineIndex int, localSteps []cfiStep, err error) {
	inner := strings.TrimPrefix(cfi, "epubcfi(")
	inner = strings.TrimSuffix(inner, ")")

	parts := strings.SplitN(inner, "!", 2)
	if len(parts) < 2 {
		return 0, nil, fmt.Errorf("no step indirection in CFI")
	}

	spineSteps := parseCFISteps(parts[0])
	if len(spineSteps) < 2 {
		return 0, nil, fmt.Errorf("no spine step in CFI")
	}
	spineIndex = spineSteps[len(spineSteps)-1].Index/2 - 1

	localPart := parts[1]
	if strings.Contains(localPart, ",") {
		commaParts := strings.SplitN(localPart, ",", 3)
		if len(commaParts) < 2 {
			return 0, nil, fmt.Errorf("invalid range CFI")
		}
		parentSteps := parseCFISteps(commaParts[0])
		startSteps := parseCFISteps(commaParts[1])
		localSteps = append(parentSteps, startSteps...)
	} else {
		localSteps = parseCFISteps(localPart)
	}

	if len(localSteps) < 1 {
		return 0, nil, fmt.Errorf("no local steps in CFI")
	}
	return spineIndex, localSteps, nil
}

func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error) {
	current := findHTMLElement(doc)
	if current == nil {
		return nil, 0, fmt.Errorf("no <html> element found in document")
	}
	for i := 0; i < len(steps)-1; i++ {
		step := steps[i]
		indexed := indexChildNodes(current)
		if step.Index >= len(indexed) {
			return nil, 0, fmt.Errorf("step %d index %d out of range (max %d)", i, step.Index, len(indexed)-1)
		}
		entry := indexed[step.Index]
		if entry.isElement() {
			current = entry.node
		} else if entry.isTextChunk() {
			current = entry.textChunk[0]
		} else {
			return nil, 0, fmt.Errorf("step %d hit virtual/null node", i)
		}
	}

	lastStep := steps[len(steps)-1]
	indexed := indexChildNodes(current)
	if lastStep.Index >= len(indexed) {
		return nil, 0, fmt.Errorf("last step index %d out of range (max %d)", lastStep.Index, len(indexed)-1)
	}
	entry := indexed[lastStep.Index]

	if entry.isTextChunk() {
		textOffset := 0
		if lastStep.HasOffset {
			textOffset = lastStep.Offset
		}

		var targetNode *html.Node
		remainingOffset := textOffset
		for _, tn := range entry.textChunk {
			textLen := utf8.RuneCountInString(tn.Data)
			if remainingOffset < textLen || (remainingOffset == textLen && targetNode == nil) {
				targetNode = tn
				break
			}
			remainingOffset -= textLen
			targetNode = tn
		}
		if targetNode == nil && len(entry.textChunk) > 0 {
			targetNode = entry.textChunk[len(entry.textChunk)-1]
		}

		parent := targetNode.Parent
		totalOffset := 0
		for c := parent.FirstChild; c != nil; c = c.NextSibling {
			if c == targetNode {
				break
			}
			totalOffset += countTextChars(c)
		}
		totalOffset += remainingOffset

		return targetNode, totalOffset, nil
	}

	if entry.isElement() {
		return entry.node, 0, nil
	}

	return nil, 0, fmt.Errorf("last step hit virtual/null node")
}

func buildCREXPointer(spineIndex int, node *html.Node, charOffset int) (string, error) {
	current := node
	if current.Type == html.TextNode {
		current = current.Parent
	}
	if current == nil || current.Type != html.ElementNode {
		return "", fmt.Errorf("no element node to build XPointer from")
	}

	var parts []string
	for current != nil {
		if current.Type != html.ElementNode {
			current = current.Parent
			continue
		}

		parent := current.Parent
		if parent == nil {
			break
		}

		if strings.EqualFold(current.Data, "body") {
			break
		}

		tagCount := 0
		for c := parent.FirstChild; c != nil; c = c.NextSibling {
			if c.Type == html.ElementNode && strings.EqualFold(c.Data, current.Data) {
				tagCount++
				if c == current {
					break
				}
			}
		}

		parts = append([]string{fmt.Sprintf("/%s[%d]", current.Data, tagCount)}, parts...)

		if parent.Type == html.ElementNode && strings.EqualFold(parent.Data, "body") {
			break
		}
		current = parent
	}

	fragIndex := spineIndex + 1
	xpointer := fmt.Sprintf("/body/DocFragment[%d]/body%s", fragIndex, strings.Join(parts, ""))

	if charOffset > 0 || (node.Type == html.TextNode) {
		xpointer += fmt.Sprintf("/text().%d", charOffset)
	}

	return xpointer, nil
}

type ReverseConversionResult struct {
	XPointer   string
	Precision  string
	Percentage float64
}

func (c *CFIConverter) ConvertStandardToCRE(epubcfi string, storedPercentage float64, contextText string) (*ReverseConversionResult, error) {
	spineIndex, localSteps, err := parseEPUBCFI(epubcfi)
	if err != nil {
		log.Printf("Bookhoard: CFI→CRE parse failed: %v", err)
		return c.reverseByTextSearch(epubcfi, storedPercentage, contextText)
	}

	doc, _, docErr := c.getContentDoc(spineIndex + 1)
	if docErr != nil {
		log.Printf("Bookhoard: CFI→CRE content doc load failed: %v", docErr)
		return c.reverseByTextSearch(epubcfi, storedPercentage, contextText)
	}

	textNode, charOffset, resolveErr := resolveCFIToNode(doc, localSteps)
	if resolveErr != nil {
		log.Printf("Bookhoard: CFI→CRE resolution failed: %v", resolveErr)
		return c.reverseByTextSearch(epubcfi, storedPercentage, contextText)
	}

	xpointer, buildErr := buildCREXPointer(spineIndex, textNode, charOffset)
	if buildErr != nil {
		log.Printf("Bookhoard: CFI→CRE build failed: %v", buildErr)
		return c.reverseByTextSearch(epubcfi, storedPercentage, contextText)
	}

	log.Printf("Bookhoard: CFI→CRE converted %s → %s", epubcfi, xpointer)
	return &ReverseConversionResult{
		XPointer:   xpointer,
		Precision:  "exact",
		Percentage: storedPercentage,
	}, nil
}

func (c *CFIConverter) reverseByTextSearch(epubcfi string, storedPercentage float64, contextText string) (*ReverseConversionResult, error) {
	normalizedCtx := normalizeWhitespace(contextText)
	if normalizedCtx == "" {
		return &ReverseConversionResult{
			Precision:  "percentage",
			Percentage: storedPercentage,
		}, nil
	}

	spineIndex, _, parseErr := parseEPUBCFI(epubcfi)
	if parseErr != nil {
		spine, err := c.loadSpine()
		if err != nil {
			return &ReverseConversionResult{
				Precision:  "percentage",
				Percentage: storedPercentage,
			}, nil
		}

		bookWideOffset := int(storedPercentage * float64(totalBookChars(c, spine)))
		spineIndex = 0
		charsBefore := 0
		for i := range spine.items {
			doc, _, docErr := c.getContentDoc(i + 1)
			if docErr != nil {
				continue
			}
			body := findBody(doc)
			if body == nil {
				continue
			}
			cc := countTextChars(body)
			if charsBefore+cc > bookWideOffset {
				spineIndex = i
				break
			}
			charsBefore += cc
		}
	}

	doc, _, docErr := c.getContentDoc(spineIndex + 1)
	if docErr != nil {
		return &ReverseConversionResult{
			Precision:  "percentage",
			Percentage: storedPercentage,
		}, nil
	}

	body := findBody(doc)
	if body == nil {
		return &ReverseConversionResult{
			Precision:  "percentage",
			Percentage: storedPercentage,
		}, nil
	}

	match, matchOffset := findTextInNode(body, normalizedCtx)
	if match == nil {
		log.Printf("Bookhoard: CFI→CRE text search no match for %q", normalizedCtx)
		return &ReverseConversionResult{
			Precision:  "percentage",
			Percentage: storedPercentage,
		}, nil
	}

	parent := match.Parent
	totalOffset := matchOffset
	for c := parent.FirstChild; c != nil; c = c.NextSibling {
		if c == match {
			break
		}
		totalOffset += countTextChars(c)
	}

	xpointer, buildErr := buildCREXPointer(spineIndex, match, totalOffset)
	if buildErr != nil {
		log.Printf("Bookhoard: CFI→CRE text search build failed: %v", buildErr)
		return &ReverseConversionResult{
			Precision:  "percentage",
			Percentage: storedPercentage,
		}, nil
	}

	log.Printf("Bookhoard: CFI→CRE text search matched → %s", xpointer)
	return &ReverseConversionResult{
		XPointer:   xpointer,
		Precision:  "exact",
		Percentage: storedPercentage,
	}, nil
}

func totalBookChars(c *CFIConverter, spine *spineCache) int {
	total := 0
	for i := range spine.items {
		doc, _, err := c.getContentDoc(i + 1)
		if err != nil {
			continue
		}
		body := findBody(doc)
		if body != nil {
			total += countTextChars(body)
		}
	}
	return total
}
