package web

import (
	"strings"
	"testing"

	"github.com/matryer/is"
	"golang.org/x/net/html"
)

func TestSanitizerXSSProtection(t *testing.T) {
	is := is.New(t)

	ctx := &ReadmeContext{
		RepoName:   "test-repo",
		CommitHash: "abc123",
		ReadmePath: "README.md",
	}

	// Test javascript: URL in link
	md := []byte(`[click me](javascript:alert('xss'))`)
	html, err := renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(!strings.Contains(string(html), "javascript:"))

	// Test javascript: URL in image
	md = []byte(`![img](javascript:alert('xss'))`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(!strings.Contains(string(html), "javascript:"))

	// Test data: URI in image
	md = []byte(`![img](data:image/png;base64,iVBORw0KG)`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(!strings.Contains(string(html), "data:image"))

	// Test data: URI in link
	md = []byte(`[link](data:text/html,<script>alert('xss')</script>)`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(!strings.Contains(string(html), "data:text"))

	// Test onerror handler
	md = []byte(`<img src="x" onerror="alert('xss')">`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(!strings.Contains(string(html), "onerror"))

	// Test onclick handler
	md = []byte(`<a href="#" onclick="alert('xss')">click</a>`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(!strings.Contains(string(html), "onclick"))

	// Test style attribute
	md = []byte(`<p style="background:url(javascript:alert('xss'))">test</p>`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(!strings.Contains(string(html), "style="))
	is.True(!strings.Contains(string(html), "javascript"))

	// Test iframe injection
	md = []byte(`<iframe src="https://evil.com"></iframe>`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(!strings.Contains(string(html), "<iframe"))

	// Test script tag
	md = []byte(`<script>alert('xss')</script>`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(!strings.Contains(string(html), "<script"))

	// Test object/embed tags
	md = []byte(`<object data="https://evil.com"></object>`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(!strings.Contains(string(html), "<object"))

	md = []byte(`<embed src="https://evil.com">`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(!strings.Contains(string(html), "<embed"))
}

func TestSanitizerAllowedTags(t *testing.T) {
	is := is.New(t)

	ctx := &ReadmeContext{
		RepoName:   "test-repo",
		CommitHash: "abc123",
		ReadmePath: "README.md",
	}

	// Test allowed basic formatting
	md := []byte(`**bold** *italic* ~~strikethrough~~`)
	html, err := renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(strings.Contains(string(html), "<strong>"))
	is.True(strings.Contains(string(html), "<em>"))
	is.True(strings.Contains(string(html), "<del>"))

	// Test allowed headings
	md = []byte(`# H1
## H2
### H3`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(strings.Contains(string(html), "<h1"))
	is.True(strings.Contains(string(html), "<h2"))
	is.True(strings.Contains(string(html), "<h3"))

	// Test allowed lists
	md = []byte(`- item 1
- item 2

1. numbered
2. list`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(strings.Contains(string(html), "<ul"))
	is.True(strings.Contains(string(html), "<ol"))
	is.True(strings.Contains(string(html), "<li"))

	// Test allowed code blocks
	md = []byte("```go\nfunc main() {}\n```")
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(strings.Contains(string(html), "<pre"))
	is.True(strings.Contains(string(html), "<code"))

	// Test allowed tables
	md = []byte(`| Col1 | Col2 |
|------|------|
| A    | B    |`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(strings.Contains(string(html), "<table"))
	is.True(strings.Contains(string(html), "<thead"))
	is.True(strings.Contains(string(html), "<tbody"))
	is.True(strings.Contains(string(html), "<tr"))
	is.True(strings.Contains(string(html), "<th"))
	is.True(strings.Contains(string(html), "<td"))

	// Test allowed blockquote
	md = []byte(`> quote`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(strings.Contains(string(html), "<blockquote"))

	// Test allowed details/summary (collapsible sections)
	md = []byte(`<details>
<summary>Click to expand</summary>

Hidden content here

</details>`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	is.True(strings.Contains(string(html), "<details"))
	is.True(strings.Contains(string(html), "<summary"))
}

func TestSanitizerHTTPSOnly(t *testing.T) {
	is := is.New(t)

	ctx := &ReadmeContext{
		RepoName:   "test-repo",
		CommitHash: "abc123",
		ReadmePath: "README.md",
	}

	// Test http:// link is stripped
	md := []byte(`[insecure](http://example.com)`)
	html, err := renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr := string(html)
	// Either no anchor tag or the anchor has no href attribute
	is.True(!hasElementWithTag(htmlStr, "a") || elementAttrAbsent(htmlStr, "a", "href"))

	// Test https:// link is allowed
	md = []byte(`[secure](https://example.com)`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr = string(html)
	is.True(elementHasAttr(htmlStr, "a", "href"))
	is.True(elementAttrContains(htmlStr, "a", "href", "https://example.com"))

	// Test http:// image is stripped
	md = []byte(`![img](http://example.com/image.png)`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr = string(html)
	is.True(!hasElementWithTag(htmlStr, "img") || elementAttrAbsent(htmlStr, "img", "src"))

	// Test https:// image is allowed
	md = []byte(`![img](https://example.com/image.png)`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr = string(html)
	is.True(elementHasAttr(htmlStr, "img", "src"))
	is.True(elementAttrContains(htmlStr, "img", "src", "https://example.com/image.png"))
}

func TestSanitizerNoFollowNoReferrer(t *testing.T) {
	is := is.New(t)

	ctx := &ReadmeContext{
		RepoName:   "test-repo",
		CommitHash: "abc123",
		ReadmePath: "README.md",
	}

	// Test that external links get rel="nofollow noreferrer"
	md := []byte(`[external](https://example.com)`)
	html, err := renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr := string(html)
	is.True(elementHasAttr(htmlStr, "a", "rel"))
	relAttr := getElementAttr(htmlStr, "a", "rel")
	is.True(strings.Contains(relAttr, "nofollow"))
	is.True(strings.Contains(relAttr, "noreferrer"))

	// Test that relative/internal links also get rel="nofollow noreferrer"
	md = []byte(`[internal](docs/README.md)`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr = string(html)
	is.True(elementHasAttr(htmlStr, "a", "rel"))
	relAttr = getElementAttr(htmlStr, "a", "rel")
	is.True(strings.Contains(relAttr, "nofollow"))
	is.True(strings.Contains(relAttr, "noreferrer"))
}

func TestSanitizerAdditionalSchemes(t *testing.T) {
	is := is.New(t)

	ctx := &ReadmeContext{
		RepoName:   "test-repo",
		CommitHash: "abc123",
		ReadmePath: "README.md",
	}

	// Test protocol-relative URLs are stripped
	md := []byte(`[protocol-relative](//evil.com/script.js)`)
	html, err := renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr := string(html)
	// Rewriter blocks protocol-relative URLs with empty href, then sanitizer strips empty href links
	is.True(!hasElementWithTag(htmlStr, "a"))

	// Test mailto: scheme is stripped
	md = []byte(`[email](mailto:user@example.com)`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr = string(html)
	// mailto: is not in allowed schemes, so sanitizer strips the entire link
	is.True(!hasElementWithTag(htmlStr, "a"))

	// Test ftp: scheme is stripped
	md = []byte(`[ftp](ftp://ftp.example.com/file.txt)`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr = string(html)
	// ftp: is not in allowed schemes, so sanitizer strips the entire link
	is.True(!hasElementWithTag(htmlStr, "a"))
}

func TestSanitizerDisallowedAttributes(t *testing.T) {
	is := is.New(t)

	ctx := &ReadmeContext{
		RepoName:   "test-repo",
		CommitHash: "abc123",
		ReadmePath: "README.md",
	}

	// Test target attribute is stripped from links
	md := []byte(`<a href="https://example.com" target="_blank">link</a>`)
	html, err := renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr := string(html)
	is.True(elementAttrAbsent(htmlStr, "a", "target"))

	// Test class attribute is stripped
	md = []byte(`<a href="https://example.com" class="dangerous">link</a>`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr = string(html)
	is.True(elementAttrAbsent(htmlStr, "a", "class"))

	// Test style attribute is stripped (already tested in XSS, but explicit here)
	md = []byte(`<a href="https://example.com" style="color: red;">link</a>`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr = string(html)
	is.True(elementAttrAbsent(htmlStr, "a", "style"))

	// Test onclick is stripped (already tested in XSS, but explicit here)
	md = []byte(`<a href="#" onclick="alert('xss')">click</a>`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr = string(html)
	is.True(elementAttrAbsent(htmlStr, "a", "onclick"))

	// Test onerror is stripped (already tested in XSS, but explicit here)
	md = []byte(`<img src="x" onerror="alert('xss')">`)
	html, err = renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr = string(html)
	is.True(elementAttrAbsent(htmlStr, "img", "onerror"))
}

func TestSanitizerImageAttributes(t *testing.T) {
	is := is.New(t)

	ctx := &ReadmeContext{
		RepoName:   "test-repo",
		CommitHash: "abc123",
		ReadmePath: "README.md",
	}

	// Test that width and height attributes are preserved on images
	md := []byte(`<img src="https://example.com/image.png" width="100" height="50" alt="test">`)
	html, err := renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr := string(html)
	is.True(elementAttrEquals(htmlStr, "img", "width", "100"))
	is.True(elementAttrEquals(htmlStr, "img", "height", "50"))
	is.True(elementAttrEquals(htmlStr, "img", "alt", "test"))
}

func TestSanitizerHeadingIDs(t *testing.T) {
	is := is.New(t)

	ctx := &ReadmeContext{
		RepoName:   "test-repo",
		CommitHash: "abc123",
		ReadmePath: "README.md",
	}

	// Test that heading IDs are preserved (generated by AutoHeadingID)
	md := []byte(`# Installation

Jump to [installation](#installation).`)
	html, err := renderMarkdown(md, ctx)
	is.NoErr(err)
	htmlStr := string(html)
	// Check that the heading has an id attribute set to "installation"
	is.True(elementAttrEquals(htmlStr, "h1", "id", "installation"))
	// Check that the anchor link is preserved
	is.True(elementAttrContains(htmlStr, "a", "href", "#installation"))
}

// HTML Testing Helpers
// These utilities help test HTML output by parsing the DOM instead of string matching.

// findElement searches the HTML tree for an element matching the given tag name.
// Returns the first matching element node, or nil if not found.
func findElement(n *html.Node, tag string) *html.Node {
	if n.Type == html.ElementNode && n.Data == tag {
		return n
	}
	for c := n.FirstChild; c != nil; c = c.NextSibling {
		if found := findElement(c, tag); found != nil {
			return found
		}
	}
	return nil
}

// findElementWithAttr searches for an element with a specific attribute value.
// Returns the first matching element, or nil if not found.
func findElementWithAttr(n *html.Node, tag, attrKey, attrValue string) *html.Node {
	if n.Type == html.ElementNode && n.Data == tag {
		if getAttr(n, attrKey) == attrValue {
			return n
		}
	}
	for c := n.FirstChild; c != nil; c = c.NextSibling {
		if found := findElementWithAttr(c, tag, attrKey, attrValue); found != nil {
			return found
		}
	}
	return nil
}

// getAttr returns the value of an attribute, or empty string if not present.
func getAttr(n *html.Node, key string) string {
	for _, attr := range n.Attr {
		if attr.Key == key {
			return attr.Val
		}
	}
	return ""
}

// hasAttr returns true if the element has the specified attribute (regardless of value).
func hasAttr(n *html.Node, key string) bool {
	for _, attr := range n.Attr {
		if attr.Key == key {
			return true
		}
	}
	return false
}

// attrContains checks if an attribute value contains a substring.
func attrContains(n *html.Node, key, substr string) bool {
	val := getAttr(n, key)
	return strings.Contains(val, substr)
}

// parseHTML parses an HTML string and returns the root node.
func parseHTML(htmlStr string) (*html.Node, error) {
	return html.Parse(strings.NewReader(htmlStr))
}

// hasElementWithTag returns true if the HTML contains an element with the given tag.
func hasElementWithTag(htmlStr, tag string) bool {
	doc, err := parseHTML(htmlStr)
	if err != nil {
		return false
	}
	return findElement(doc, tag) != nil
}

// getElementAttr finds an element by tag and returns the value of the specified attribute.
// Returns empty string if element or attribute not found.
func getElementAttr(htmlStr, tag, attrKey string) string {
	doc, err := parseHTML(htmlStr)
	if err != nil {
		return ""
	}
	elem := findElement(doc, tag)
	if elem == nil {
		return ""
	}
	return getAttr(elem, attrKey)
}

// elementHasAttr checks if an element has a specific attribute key (regardless of value).
func elementHasAttr(htmlStr, tag, attrKey string) bool {
	doc, err := parseHTML(htmlStr)
	if err != nil {
		return false
	}
	elem := findElement(doc, tag)
	if elem == nil {
		return false
	}
	return hasAttr(elem, attrKey)
}

// elementAttrEquals checks if an element's attribute equals a specific value.
func elementAttrEquals(htmlStr, tag, attrKey, expected string) bool {
	return getElementAttr(htmlStr, tag, attrKey) == expected
}

// elementAttrContains checks if an element's attribute contains a substring.
func elementAttrContains(htmlStr, tag, attrKey, substr string) bool {
	doc, err := parseHTML(htmlStr)
	if err != nil {
		return false
	}
	elem := findElement(doc, tag)
	if elem == nil {
		return false
	}
	return attrContains(elem, attrKey, substr)
}

// elementAttrAbsent checks that an element does NOT have a specific attribute.
func elementAttrAbsent(htmlStr, tag, attrKey string) bool {
	return !elementHasAttr(htmlStr, tag, attrKey)
}

// elementAttrEmpty checks if an element's attribute is present but empty.
func elementAttrEmpty(htmlStr, tag, attrKey string) bool {
	doc, err := parseHTML(htmlStr)
	if err != nil {
		return false
	}
	elem := findElement(doc, tag)
	if elem == nil {
		return false
	}
	if !hasAttr(elem, attrKey) {
		return false
	}
	return getAttr(elem, attrKey) == ""
}
