// Package markup is an umbrella package for all markups and their renderers.
package markup

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"net/url"
	"path"
	"strings"
	"time"

	chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
	"github.com/alecthomas/chroma/v2/styles"
	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/yuin/goldmark"
	emoji "github.com/yuin/goldmark-emoji"
	highlighting "github.com/yuin/goldmark-highlighting/v2"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer/html"
	"github.com/yuin/goldmark/text"
	"github.com/yuin/goldmark/util"
	callout "gitlab.com/staticnoise/goldmark-callout"
	"go.abhg.dev/goldmark/mermaid"
	htmlparse "golang.org/x/net/html"

	textension "tangled.org/core/appview/pages/markup/extension"
	"tangled.org/core/appview/pages/repoinfo"
)

// RendererType defines the type of renderer to use based on context
type RendererType int

const (
	// RendererTypeRepoMarkdown is for repository documentation markdown files
	RendererTypeRepoMarkdown RendererType = iota
	// RendererTypeDefault is non-repo markdown, like issues/pulls/comments.
	RendererTypeDefault
)

// RenderContext holds the contextual data for rendering markdown.
// It can be initialized empty, and that'll skip any transformations.
type RenderContext struct {
	CamoUrl    string
	CamoSecret string
	repoinfo.RepoInfo
	IsDev        bool
	Hostname     string
	RendererType RendererType
	Directory    identity.Directory
}

func NewMarkdown(hostname string, extra ...goldmark.Extender) goldmark.Markdown {
	exts := []goldmark.Extender{
		extension.GFM,
		&mermaid.Extender{
			RenderMode: mermaid.RenderModeClient,
			NoScript:   true,
		},
		highlighting.NewHighlighting(
			highlighting.WithFormatOptions(
				chromahtml.Standalone(false),
				chromahtml.WithClasses(true),
			),
			highlighting.WithCustomStyle(styles.Get("catppuccin-latte")),
		),
		extension.NewFootnote(
			extension.WithFootnoteIDPrefix([]byte("footnote")),
		),
		callout.CalloutExtention,
		textension.MathExt,
		textension.AtExt,
		textension.NewTangledLinkExt(hostname),
		emoji.Emoji,
	}
	exts = append(exts, extra...)
	md := goldmark.New(
		goldmark.WithExtensions(exts...),
		goldmark.WithParserOptions(
			parser.WithAutoHeadingID(),
		),
		goldmark.WithRendererOptions(html.WithUnsafe()),
	)
	return md
}

// clone creates a shallow copy of the RenderContext
func (rctx *RenderContext) Clone() *RenderContext {
	if rctx == nil {
		return nil
	}
	clone := *rctx
	return &clone
}

// NewMarkdownWith is an alias for NewMarkdown with extra extensions.
func NewMarkdownWith(hostname string, extra ...goldmark.Extender) goldmark.Markdown {
	return NewMarkdown(hostname, extra...)
}

func (rctx *RenderContext) RenderMarkdown(source string) string {
	return rctx.RenderMarkdownWith(source, NewMarkdown(rctx.Hostname))
}

func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string {
	if rctx != nil {
		var transformers []util.PrioritizedValue

		transformers = append(transformers, util.Prioritized(&MarkdownTransformer{rctx: rctx}, 10000))

		md.Parser().AddOptions(
			parser.WithASTTransformers(transformers...),
		)
	}

	var buf bytes.Buffer
	if err := md.Convert([]byte(source), &buf); err != nil {
		return source
	}

	var processed strings.Builder
	if err := postProcess(rctx, strings.NewReader(buf.String()), &processed); err != nil {
		return source
	}

	return processed.String()
}

func postProcess(ctx *RenderContext, input io.Reader, output io.Writer) error {
	node, err := htmlparse.Parse(io.MultiReader(
		strings.NewReader("<html><body>"),
		input,
		strings.NewReader("</body></html>"),
	))
	if err != nil {
		return fmt.Errorf("failed to parse html: %w", err)
	}

	if node.Type == htmlparse.DocumentNode {
		node = node.FirstChild
	}

	visitNode(ctx, node)

	newNodes := make([]*htmlparse.Node, 0, 5)

	if node.Data == "html" {
		node = node.FirstChild
		for node != nil && node.Data != "body" {
			node = node.NextSibling
		}
	}
	if node != nil {
		if node.Data == "body" {
			child := node.FirstChild
			for child != nil {
				newNodes = append(newNodes, child)
				child = child.NextSibling
			}
		} else {
			newNodes = append(newNodes, node)
		}
	}

	for _, node := range newNodes {
		if err := htmlparse.Render(output, node); err != nil {
			return fmt.Errorf("failed to render processed html: %w", err)
		}
	}

	return nil
}

func visitNode(ctx *RenderContext, node *htmlparse.Node) {
	switch node.Type {
	case htmlparse.ElementNode:
		switch node.Data {
		case "a":
			// TODO: transform `./` or `/` links to tree link
		case "img", "source":
			var blobCid syntax.CID
			for i, attr := range node.Attr {
				if attr.Key != "src" {
					continue
				}

				if strings.HasPrefix(attr.Val, blobURIScheme) {
					if _, cid, ok := parseBlobURI(attr.Val); ok {
						blobCid = cid
					}
					if blobUrl, ok := ctx.blobToGetBlobURL(attr.Val); ok {
						attr.Val = ctx.camoImageLinkTransformer(blobUrl)
						node.Attr[i] = attr
					}
					continue
				}

				if isAbsoluteUrl(attr.Val) {
					// apply camo to external links
					camoUrl, _ := url.Parse(ctx.CamoUrl)
					dstUrl, _ := url.Parse(attr.Val)
					if camoUrl != nil && dstUrl != nil && dstUrl.Host != ctx.Hostname && dstUrl.Host != camoUrl.Host {
						attr.Val = ctx.camoImageLinkTransformer(attr.Val)
					}
				} else {
					attr.Val = ctx.imageToRawTransformer(attr.Val)
				}
				node.Attr[i] = attr
			}
			// tag with the cid so the editor can preview an uncommitted blob
			// (appended after the loop to avoid mutating node.Attr mid-range)
			if blobCid != "" {
				node.Attr = append(node.Attr, htmlparse.Attribute{Key: "data-blob-cid", Val: blobCid.String()})
			}
		}

		for n := node.FirstChild; n != nil; n = n.NextSibling {
			visitNode(ctx, n)
		}
	default:
	}
}

type MarkdownTransformer struct {
	rctx *RenderContext
}

func (a *MarkdownTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
	_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}

		switch a.rctx.RendererType {
		case RendererTypeRepoMarkdown:
			switch n := n.(type) {
			case *ast.Heading:
				a.rctx.anchorHeadingTransformer(n)
			case *ast.Link:
				// TODO: run this on HTML transformation instead
				a.rctx.relativeLinkTransformer(n)
			}
		case RendererTypeDefault:
			switch n := n.(type) {
			case *ast.Heading:
				a.rctx.anchorHeadingTransformer(n)
			}
		}

		return ast.WalkContinue, nil
	})
}

func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) {

	dst := string(link.Destination)

	if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) {
		return
	}

	actualPath := rctx.actualPath(dst)

	newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, actualPath)
	link.Destination = []byte(newPath)
}

// blobURIScheme embeds a PDS blob in markdown independent of the PDS hostname:
// blob+at://<did>/<cid>.
const blobURIScheme = "blob+at://"

func parseBlobURI(src string) (syntax.DID, syntax.CID, bool) {
	rest, ok := strings.CutPrefix(src, blobURIScheme)
	if !ok {
		return "", "", false
	}
	rawDid, rawCid, ok := strings.Cut(rest, "/")
	if !ok || rawDid == "" || rawCid == "" {
		return "", "", false
	}
	did, err := syntax.ParseDID(rawDid)
	if err != nil {
		return "", "", false
	}
	cid, err := syntax.ParseCID(rawCid)
	if err != nil {
		return "", "", false
	}
	return did, cid, true
}

func (rctx *RenderContext) blobToGetBlobURL(src string) (string, bool) {
	did, cid, ok := parseBlobURI(src)
	if !ok {
		return src, false
	}
	if rctx.Directory == nil {
		return src, false
	}
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	ident, err := rctx.Directory.LookupDID(ctx, did)
	if err != nil {
		return src, false
	}
	pds := ident.PDSEndpoint()
	if pds == "" {
		return src, false
	}
	// TODO: avoid directly fetching from PDS. use services like porxie instead.
	u, err := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", pds))
	if err != nil {
		return src, false
	}
	q := u.Query()
	q.Set("did", did.String())
	q.Set("cid", cid.String())
	u.RawQuery = q.Encode()
	return u.String(), true
}

func (rctx *RenderContext) imageToRawTransformer(dst string) string {
	if isAbsoluteUrl(dst) {
		return dst
	}

	actualPath := rctx.actualPath(dst)

	newDest := path.Join("/", rctx.RepoInfo.FullName(), "raw", rctx.RepoInfo.Ref, actualPath)
	return newDest
}

func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) {
	idGeneric, exists := h.AttributeString("id")
	if !exists {
		return // no id, nothing to do
	}
	id, ok := idGeneric.([]byte)
	if !ok {
		return
	}

	// create anchor link
	anchor := ast.NewLink()
	anchor.Destination = fmt.Appendf(nil, "#%s", string(id))
	anchor.SetAttribute([]byte("class"), []byte("anchor"))

	// create icon text
	iconText := ast.NewString([]byte("#"))
	anchor.AppendChild(anchor, iconText)

	// set class on heading
	h.SetAttribute([]byte("class"), []byte("heading"))

	// append anchor to heading
	h.AppendChild(h, anchor)
}

// actualPath decides when to join the file path with the
// current repository directory (essentially only when the link
// destination is relative. if it's absolute then we assume the
// user knows what they're doing.)
func (rctx *RenderContext) actualPath(dst string) string {
	if path.IsAbs(dst) {
		return dst
	}

	return path.Join(rctx.CurrentDir, dst)
}

func isAbsoluteUrl(link string) bool {
	parsed, err := url.Parse(link)
	if err != nil {
		return false
	}
	return parsed.IsAbs()
}

func isFragment(link string) bool {
	return strings.HasPrefix(link, "#")
}

func isMail(link string) bool {
	return strings.HasPrefix(link, "mailto:")
}
