package main

import (
	"bufio"
	"bytes"
	"regexp"
	"slices"
	"strings"

	"github.com/yuin/goldmark/ast"
)

var (
	// Setup regular expression for checking versions and valid choices
	// Matches HTML comments (e.g., <!-- some comment -->) surrounded by optional whitespace
	metaComment = regexp.MustCompile(`(?:\s*<!-- .* -->\s*)`)

	// Matches Telegraf versioning format (e.g., "Telegraf v1.2.3")
	metaVersion = regexp.MustCompile(`^Telegraf v\d+\.\d+\.\d+(?:\s+<!-- .* -->\s*)?$`)

	metaTags = map[plugin][]string{
		pluginInput: {
			"applications",
			"cloud",
			"containers",
			"datastore",
			"hardware",
			"iot",
			"logging",
			"messaging",
			"network",
			"server",
			"system",
			"testing",
			"web",
		},
		pluginOutput: {
			"applications",
			"cloud",
			"containers",
			"datastore",
			"hardware",
			"iot",
			"logging",
			"messaging",
			"network",
			"server",
			"system",
			"testing",
			"web",
		},
		pluginAggregator: {
			"math",
			"sampling",
			"statistics",
			"transformation",
		},
		pluginProcessor: {
			"annotation",
			"cloud",
			"filtering",
			"general purpose",
			"grouping",
			"testing",
			"transformation",
		},
	}

	metaOSes = []string{
		"all",
		"freebsd",
		"linux",
		"macos",
		"solaris",
		"windows",
	}

	metaOrder = []string{
		"introduction version",
		"deprecation version",
		"removal version",
		"tags",
		"operating systems",
	}
)

// The first section is a heading with plugin name and paragraph short
// description
func firstSection(t *T, root ast.Node) error {
	var n ast.Node
	n = root.FirstChild()

	// Ignore HTML comments such as linter ignore sections
	for {
		if n == nil {
			break
		}
		if _, ok := n.(*ast.HTMLBlock); !ok {
			break
		}
		n = n.NextSibling()
	}

	t.assertKind(ast.KindHeading, n)
	t.assertHeadingLevel(1, n)
	t.assertFirstChildRegexp(` Plugin$`, n)

	// Make sure there is some text after the heading
	n = n.NextSibling()
	t.assertKind(ast.KindParagraph, n)
	length := len(n.(*ast.Paragraph).Lines().Value(t.markdown))
	if length < 30 {
		t.assertNodef(n, "short first section. Please add short description of plugin. length %d, minimum 30", length)
	}

	return nil
}

// Somewhere there should be a heading "sample configuration" and a
// toml code block. The toml should match what is in the plugin's go
// code

// Second level headings should include
func requiredSections(t *T, root ast.Node, headings []string) error {
	headingsSet := newSet(headings)

	expectedLevel := 2

	titleCounts := make(map[string]int)

	for n := root.FirstChild(); n != nil; n = n.NextSibling() {
		var h *ast.Heading
		var ok bool
		if h, ok = n.(*ast.Heading); !ok {
			continue
		}

		child := h.FirstChild()
		if child == nil {
			continue
		}
		//nolint:staticcheck // need to use this since we aren't sure the type
		title := strings.TrimSpace(string(child.Text(t.markdown)))
		if headingsSet.has(title) && h.Level != expectedLevel {
			t.assertNodef(n, "has required section %q but wrong heading level. Expected level %d, found %d",
				title, expectedLevel, h.Level)
		}

		titleCounts[title]++
	}

	headingsSet.forEach(func(title string) {
		if _, exists := titleCounts[title]; !exists {
			t.assertf("missing required section %q", title)
		}
	})

	return nil
}

// Use this to make a rule that looks for a list of settings. (this is
// a closure of func requiredSection)
func requiredSectionsClose(headings []string) func(*T, ast.Node) error {
	return func(t *T, root ast.Node) error {
		return requiredSections(t, root, headings)
	}
}

func noLongLinesInParagraphs(threshold int) func(*T, ast.Node) error {
	return func(t *T, root ast.Node) error {
		// We're looking for long lines in paragraphs. Find paragraphs
		// first, then which lines are in paragraphs
		paraLines := make([]int, 0)
		for n := root.FirstChild(); n != nil; n = n.NextSibling() {
			var p *ast.Paragraph
			var ok bool
			if p, ok = n.(*ast.Paragraph); !ok {
				continue // only looking for paragraphs
			}

			segs := p.Lines()
			for _, seg := range segs.Sliced(0, segs.Len()) {
				line := t.line(seg.Start)
				paraLines = append(paraLines, line)
			}
		}

		// Find long lines in the whole file
		longLines := make([]int, 0, len(t.newlineOffsets))
		last := 0
		for i, cur := range t.newlineOffsets {
			length := cur - last - 1 // -1 to exclude the newline
			if length > threshold {
				longLines = append(longLines, i)
			}
			last = cur
		}

		// Merge both lists
		p := 0
		l := 0
		bads := make([]int, 0, max(len(paraLines), len(longLines)))
		for p < len(paraLines) && l < len(longLines) {
			long := longLines[l]
			para := paraLines[p]
			switch {
			case long == para:
				bads = append(bads, long)
				p++
				l++
			case long < para:
				l++
			case long > para:
				p++
			}
		}

		for _, bad := range bads {
			t.assertLinef(bad, "long line in paragraph")
		}
		return nil
	}
}

func configSection(t *T, root ast.Node) error {
	var config *ast.Heading
	config = nil
	expectedTitle := "Configuration"
	for n := root.FirstChild(); n != nil; n = n.NextSibling() {
		var h *ast.Heading
		var ok bool
		if h, ok = n.(*ast.Heading); !ok {
			continue
		}

		//nolint:staticcheck // need to use this since we aren't sure the type
		title := string(h.FirstChild().Text(t.markdown))
		if title == expectedTitle {
			config = h
			continue
		}
	}

	if config == nil {
		t.assertf("missing required section %q", expectedTitle)
		return nil
	}

	toml := config.NextSibling()
	if toml == nil {
		t.assertNodef(toml, "missing config next sibling")
		return nil
	}

	var b *ast.FencedCodeBlock
	var ok bool
	if b, ok = toml.(*ast.FencedCodeBlock); !ok {
		t.assertNodef(toml, "config next sibling isn't a fenced code block")
		return nil
	}

	if !bytes.Equal(b.Language(t.markdown), []byte("toml")) {
		t.assertNodef(b, "config fenced code block isn't toml language")
		return nil
	}

	return nil
}

// Links from one markdown file to another in the repo should be relative
func relativeTelegrafLinks(t *T, root ast.Node) error {
	for n := root.FirstChild(); n != nil; n = n.NextSibling() {
		if _, ok := n.(*ast.Paragraph); !ok {
			continue
		}

		for n2 := n.FirstChild(); n2 != nil; n2 = n2.NextSibling() {
			var l *ast.Link
			var ok bool
			if l, ok = n2.(*ast.Link); !ok {
				continue
			}
			link := string(l.Destination)
			if strings.HasPrefix(link, "https://github.com/influxdata/telegraf/blob") {
				t.assertNodef(n, "in-repo link must be relative: %s", link)
			}
		}
	}
	return nil
}

// Each plugin should have metadata for documentation generation
func metadata(t *T, root ast.Node) error {
	const icons string = "⭐🚩🔥🏷️💻"

	n := root.FirstChild()
	if n == nil {
		t.assertf("no metadata section found")
		return nil
	}

	// Advance to the first heading which should be the plugin header
	for n != nil {
		if _, ok := n.(*ast.Heading); ok {
			t.assertHeadingLevel(1, n)
			break
		}
		n = n.NextSibling()
	}

	// Get the description text and check for metadata
	positions := make([]string, 0, 5)
	for n != nil {
		n = n.NextSibling()

		// The next heading will end the initial section
		if _, ok := n.(*ast.Heading); ok {
			break
		}

		// Ignore everything that is not text
		para, ok := n.(*ast.Paragraph)
		if !ok {
			continue
		}

		// Metadata should be separate paragraph with the items ordered.
		var inMetadata bool
		var counter int
		scanner := bufio.NewScanner(bytes.NewBuffer(para.Lines().Value(t.markdown)))
		for scanner.Scan() {
			txt := scanner.Text()
			if counter == 0 {
				inMetadata = strings.ContainsAny(txt, icons)
			}
			counter++

			// If we are not in a metadata section, we need to make sure we don't
			// see any metadata in this text.
			if !inMetadata {
				if strings.ContainsAny(txt, icons) {
					t.assertNodeLineOffsetf(n, counter-1, "metadata found in section not surrounded by empty lines")
					return nil
				}
				continue
			}

			icon, remainder, found := strings.Cut(txt, " ")
			if !found || !strings.Contains(icons, icon) {
				t.assertNodeLineOffsetf(n, counter-1, "metadata line must start with a valid icon and a space")
				continue
			}
			if strings.ContainsAny(remainder, icons) {
				t.assertNodeLineOffsetf(n, counter-1, "each metadata entry must be on a separate line")
				continue
			}

			// We are in a metadata section, so test for the correct structure
			switch icon {
			case "⭐":
				if !metaVersion.MatchString(remainder) {
					t.assertNodeLineOffsetf(n, counter-1, "invalid introduction version format; has to be 'Telegraf vX.Y.Z'")
				}
				positions = append(positions, "introduction version")
			case "🚩":
				if !metaVersion.MatchString(remainder) {
					t.assertNodeLineOffsetf(n, counter-1, "invalid deprecation version format; has to be 'Telegraf vX.Y.Z'")
				}
				positions = append(positions, "deprecation version")
			case "🔥":
				if !metaVersion.MatchString(remainder) {
					t.assertNodeLineOffsetf(n, counter-1, "invalid removal version format; has to be 'Telegraf vX.Y.Z'")
				}
				positions = append(positions, "removal version")
			case "🏷️":
				validTags, found := metaTags[t.pluginType]
				if !found {
					t.assertNodeLineOffsetf(n, counter-1, "no tags expected for plugin type")
					continue
				}

				for _, tag := range strings.Split(remainder, ",") {
					tag = metaComment.ReplaceAllString(tag, "")
					if !slices.Contains(validTags, strings.TrimSpace(tag)) {
						t.assertNodeLineOffsetf(n, counter-1, "unknown tag %q", tag)
					}
				}
				positions = append(positions, "tags")
			case "💻":
				for _, os := range strings.Split(remainder, ",") {
					os = metaComment.ReplaceAllString(os, "")
					if !slices.Contains(metaOSes, strings.TrimSpace(os)) {
						t.assertNodeLineOffsetf(n, counter-1, "unknown operating system %q", os)
					}
				}
				positions = append(positions, "operating systems")
			default:
				t.assertNodeLineOffsetf(n, counter-1, "invalid metadata icon")
				continue
			}
		}
	}

	if len(positions) == 0 {
		t.assertf("metadata is missing")
		return nil
	}

	// Check for duplicate entries
	seen := make(map[string]bool)
	for _, p := range positions {
		if seen[p] {
			t.assertNodef(n, "duplicate metadata entry for %q", p)
			return nil
		}
		seen[p] = true
	}

	// Remove the optional entries from the checklist
	validOrder := append(make([]string, 0, len(metaOrder)), metaOrder...)
	if !slices.Contains(positions, "deprecation version") && !slices.Contains(positions, "removal version") {
		idx := slices.Index(validOrder, "deprecation version")
		validOrder = slices.Delete(validOrder, idx, idx+1)
		idx = slices.Index(validOrder, "removal version")
		validOrder = slices.Delete(validOrder, idx, idx+1)
	}
	if _, found := metaTags[t.pluginType]; !found {
		idx := slices.Index(metaOrder, "tags")
		metaOrder = slices.Delete(metaOrder, idx, idx+1)
	}

	// Check the order of the metadata entries and required entries
	if len(validOrder) != len(positions) {
		for _, v := range validOrder {
			if !slices.Contains(positions, v) {
				t.assertNodef(n, "metadata entry for %q is missing", v)
			}
		}
		return nil
	}

	for i, v := range validOrder {
		if v != positions[i] {
			if i == 0 {
				t.assertNodef(n, "%q has to be the first entry", v)
			} else {
				t.assertNodef(n, "%q has to follow %q", v, validOrder[i-1])
			}
			return nil
		}
	}

	return nil
}

// To do: Check markdown files that aren't plugin readme files for paragraphs
// with long lines

// To do: Check the toml inside the configuration section for syntax errors
