package parsing

import (
	"context"
	"crypto/tls"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"html"
	"net/http"
	"os"
	"regexp"
	"sort"
	"strings"
	"time"

	"github.com/mmcdole/gofeed"
)

const DefaultFeedURL = "https://aws.amazon.com/security/security-bulletins/rss/feed/"

type ItemView struct {
	Title       string    `json:"title"`
	Link        string    `json:"link"`
	Published   time.Time `json:"published"`
	Description string    `json:"description"`
}

type ItemDetails struct {
	BulletinID       string    `json:"bulletinId,omitempty"`
	Scope            string    `json:"scope,omitempty"`
	ContentType      string    `json:"contentType,omitempty"`
	PublicationDate  string    `json:"publicationDate,omitempty"`
	UpdatedDate      string    `json:"updatedDate,omitempty"`
	AffectedVersions string    `json:"affectedVersions,omitempty"`
	Resolution       string    `json:"resolution,omitempty"`
	Workarounds      string    `json:"workarounds,omitempty"`
	References       string    `json:"references,omitempty"`
	Acknowledgement  string    `json:"acknowledgement,omitempty"`
	CVEs             []string  `json:"cves,omitempty"`
	Advisories       []string  `json:"advisories,omitempty"`
	Description      string    `json:"description,omitempty"`
	Title            string    `json:"title"`
	Link             string    `json:"link"`
	Published        time.Time `json:"published"`
}

type Options struct {
	FeedURL            string
	AsJSON             bool
	Limit              int
	Since              time.Time
	InsecureSkipVerify bool
	Timeout            time.Duration
	Details            bool
}

// RunByCount returns a human-readable string for the latest N items (0=all).
// It aggregates all fields into a single string and returns it.
func RunByCount(count int) (string, error) {
	if count < 0 {
		count = 0
	}

	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()

	items, err := FetchAndParse(ctx, DefaultFeedURL, false, 20*time.Second)
	if err != nil {
		return "", err
	}

	filtered := FilterAndSort(items, time.Time{}, count)

	var b strings.Builder
	if len(filtered) == 0 {
		b.WriteString("No items found.")
		return b.String(), nil
	}

	for idx, it := range filtered {
		d := ParseDetailsFromHTML(it.Description)
		d.Title = it.Title
		d.Link = it.Link
		d.Published = it.Published
		if d.BulletinID == "" {
			if id := ExtractAWSBulletinID(it.Link, it.Title); id != "" {
				d.BulletinID = id
			}
		}

		b.WriteString("Title      : ")
		b.WriteString(d.Title)
		b.WriteString("\n")
		if d.BulletinID != "" {
			b.WriteString("BulletinID : ")
			b.WriteString(d.BulletinID)
			b.WriteString("\n")
		}
		if !d.Published.IsZero() {
			b.WriteString("Published  : ")
			b.WriteString(d.Published.Format(time.RFC3339))
			b.WriteString("\n")
		}
		if d.Scope != "" {
			b.WriteString("Scope      : ")
			b.WriteString(d.Scope)
			b.WriteString("\n")
		}
		if d.ContentType != "" {
			b.WriteString("Type       : ")
			b.WriteString(d.ContentType)
			b.WriteString("\n")
		}
		if d.PublicationDate != "" {
			b.WriteString("PubDate    : ")
			b.WriteString(d.PublicationDate)
			b.WriteString("\n")
		}
		if d.UpdatedDate != "" {
			b.WriteString("Updated    : ")
			b.WriteString(d.UpdatedDate)
			b.WriteString("\n")
		}
		if d.AffectedVersions != "" {
			b.WriteString("Affected   : ")
			b.WriteString(d.AffectedVersions)
			b.WriteString("\n")
		}
		if d.Resolution != "" {
			b.WriteString("Resolution : ")
			b.WriteString(d.Resolution)
			b.WriteString("\n")
		}
		if d.Workarounds != "" {
			b.WriteString("Workarounds: ")
			b.WriteString(d.Workarounds)
			b.WriteString("\n")
		}
		if d.References != "" {
			b.WriteString("References : ")
			b.WriteString(d.References)
			b.WriteString("\n")
		}
		if len(d.CVEs) > 0 {
			b.WriteString("CVEs       : ")
			b.WriteString(strings.Join(d.CVEs, ", "))
			b.WriteString("\n")
		}
		if len(d.Advisories) > 0 {
			b.WriteString("Advisories : ")
			b.WriteString(strings.Join(d.Advisories, ", "))
			b.WriteString("\n")
		}
		if d.Acknowledgement != "" {
			b.WriteString("Thanks     : ")
			b.WriteString(d.Acknowledgement)
			b.WriteString("\n")
		}
		if d.Description != "" {
			b.WriteString("Description: ")
			b.WriteString(d.Description)
			b.WriteString("\n")
		}
		if d.Link != "" {
			b.WriteString("Link       : ")
			b.WriteString(d.Link)
			b.WriteString("\n")
		}
		if idx < len(filtered)-1 {
			b.WriteString("\n")
		}
	}

	return b.String(), nil
}

// runCLI and flag-based logic retained for library-style reuse (not used by main)
func runCLI() error {
	var (
		feedURL            string
		asJSON             bool
		limit              int
		sinceStr           string
		insecureSkipVerify bool
		timeoutSec         int
		details            bool
	)

	flag.StringVar(&feedURL, "url", DefaultFeedURL, "RSS feed URL to fetch")
	flag.BoolVar(&asJSON, "json", false, "Output as JSON instead of table")
	flag.IntVar(&limit, "limit", 0, "Limit number of items (0 = all)")
	flag.StringVar(&sinceStr, "since", "", "Only include items published since this RFC3339 time (e.g. 2025-07-01T00:00:00Z)")
	flag.BoolVar(&insecureSkipVerify, "insecure", false, "Skip TLS verification (not recommended)")
	flag.IntVar(&timeoutSec, "timeout", 20, "HTTP timeout in seconds")
	flag.BoolVar(&details, "details", false, "Parse and output detailed fields from RSS HTML")
	flag.Parse()

	var since time.Time
	if sinceStr != "" {
		var err error
		since, err = time.Parse(time.RFC3339, sinceStr)
		if err != nil {
			return fmt.Errorf("invalid --since value, must be RFC3339: %w", err)
		}
	}

	opts := Options{
		FeedURL:            feedURL,
		AsJSON:             asJSON,
		Limit:              limit,
		Since:              since,
		InsecureSkipVerify: insecureSkipVerify,
		Timeout:            time.Duration(timeoutSec) * time.Second,
		Details:            details,
	}
	return Run(opts)
}

// Run executes the bulletin fetch, filter, and print logic using the provided options.
func Run(opts Options) error {
	ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
	defer cancel()

	feedURL := opts.FeedURL
	if strings.TrimSpace(feedURL) == "" {
		feedURL = DefaultFeedURL
	}

	items, err := FetchAndParse(ctx, feedURL, opts.InsecureSkipVerify, opts.Timeout)
	if err != nil {
		return err
	}

	filtered := FilterAndSort(items, opts.Since, opts.Limit)

	if opts.Details {
		detailed := make([]ItemDetails, 0, len(filtered))
		for _, it := range filtered {
			d := ParseDetailsFromHTML(it.Description)
			d.Title = it.Title
			d.Link = it.Link
			d.Published = it.Published
			if d.BulletinID == "" {
				if id := ExtractAWSBulletinID(it.Link, it.Title); id != "" {
					d.BulletinID = id
				}
			}
			detailed = append(detailed, d)
		}
		if opts.AsJSON {
			enc := json.NewEncoder(os.Stdout)
			enc.SetIndent("", "  ")
			_ = enc.Encode(detailed)
			return nil
		}
		printDetails(detailed)
		return nil
	}

	if opts.AsJSON {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		_ = enc.Encode(filtered)
		return nil
	}

	printTable(filtered)
	return nil
}

func FetchAndParse(ctx context.Context, url string, insecure bool, timeout time.Duration) ([]*gofeed.Item, error) {
	transport := &http.Transport{}
	if insecure {
		transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
	}
	client := &http.Client{Timeout: timeout, Transport: transport}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", "security-bulletin-rss-parser/1.0 (+https://aws.amazon.com/security/security-bulletins/)")

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status)
	}

	parser := gofeed.NewParser()
	feed, err := parser.Parse(resp.Body)
	if err != nil {
		return nil, err
	}
	if feed == nil {
		return nil, errors.New("empty feed")
	}
	return feed.Items, nil
}

func FilterAndSort(items []*gofeed.Item, since time.Time, limit int) []ItemView {
	views := make([]ItemView, 0, len(items))
	for _, it := range items {
		if it == nil {
			continue
		}
		var published time.Time
		if it.PublishedParsed != nil {
			published = *it.PublishedParsed
		} else if it.UpdatedParsed != nil {
			published = *it.UpdatedParsed
		}
		if !since.IsZero() && (published.IsZero() || published.Before(since)) {
			continue
		}
		desc := strings.TrimSpace(it.Description)
		views = append(views, ItemView{
			Title:       strings.TrimSpace(it.Title),
			Link:        strings.TrimSpace(it.Link),
			Published:   published,
			Description: desc,
		})
	}

	sort.Slice(views, func(i, j int) bool { return views[i].Published.After(views[j].Published) })
	if limit > 0 && limit < len(views) {
		return views[:limit]
	}
	return views
}

func printTable(items []ItemView) {
	if len(items) == 0 {
		fmt.Println("No items found.")
		return
	}
	fmt.Printf("%-12s  %-30s  %s\n", "PUBLISHED", "ID", "TITLE")
	fmt.Printf("%-12s  %-30s  %s\n", strings.Repeat("-", 12), strings.Repeat("-", 30), strings.Repeat("-", 50))
	for _, it := range items {
		date := ""
		if !it.Published.IsZero() {
			date = it.Published.Format("2006-01-02")
		}
		id := ExtractAWSBulletinID(it.Link, it.Title)
		fmt.Printf("%-12s  %-30s  %s\n", date, id, Truncate(it.Title, 80))
	}
}

func printDetails(items []ItemDetails) {
	if len(items) == 0 {
		fmt.Println("No items found.")
		return
	}
	for idx, it := range items {
		fmt.Printf("Title      : %s\n", it.Title)
		if it.BulletinID != "" {
			fmt.Printf("BulletinID : %s\n", it.BulletinID)
		}
		if !it.Published.IsZero() {
			fmt.Printf("Published  : %s\n", it.Published.Format(time.RFC3339))
		}
		if it.Scope != "" {
			fmt.Printf("Scope      : %s\n", it.Scope)
		}
		if it.ContentType != "" {
			fmt.Printf("Type       : %s\n", it.ContentType)
		}
		if it.PublicationDate != "" {
			fmt.Printf("PubDate    : %s\n", it.PublicationDate)
		}
		if it.UpdatedDate != "" {
			fmt.Printf("Updated    : %s\n", it.UpdatedDate)
		}
		if it.AffectedVersions != "" {
			fmt.Printf("Affected   : %s\n", it.AffectedVersions)
		}
		if it.Resolution != "" {
			fmt.Printf("Resolution : %s\n", it.Resolution)
		}
		if it.Workarounds != "" {
			fmt.Printf("Workarounds: %s\n", it.Workarounds)
		}
		if it.References != "" {
			fmt.Printf("References : %s\n", it.References)
		}
		if len(it.CVEs) > 0 {
			fmt.Printf("CVEs       : %s\n", strings.Join(it.CVEs, ", "))
		}
		if len(it.Advisories) > 0 {
			fmt.Printf("Advisories : %s\n", strings.Join(it.Advisories, ", "))
		}
		if it.Acknowledgement != "" {
			fmt.Printf("Thanks     : %s\n", it.Acknowledgement)
		}
		if it.Description != "" {
			fmt.Printf("Description: %s\n", it.Description)
		}
		if it.Link != "" {
			fmt.Printf("Link       : %s\n", it.Link)
		}
		if idx < len(items)-1 {
			fmt.Println()
		}
	}
}

func ExtractAWSBulletinID(link, title string) string {
	parts := strings.Split(strings.Trim(link, "/"), "/")
	for i := len(parts) - 1; i >= 0; i-- {
		p := strings.ToUpper(parts[i])
		if strings.HasPrefix(p, "AWS-") {
			return p
		}
	}
	u := strings.ToUpper(title)
	idx := strings.Index(u, "AWS-")
	if idx >= 0 {
		end := strings.IndexAny(u[idx:], " \t\n\r")
		if end > 0 {
			return u[idx : idx+end]
		}
		return u[idx:]
	}
	return ""
}

func Truncate(s string, max int) string {
	if len([]rune(s)) <= max {
		return s
	}
	r := []rune(s)
	return string(r[:max-1]) + "…"
}

// parseDetailsFromHTML extracts key details from the HTML snippet included in the RSS item's description.
// It is robust to minor formatting differences and converts <br> into newlines before parsing key:value pairs.
func ParseDetailsFromHTML(htmlStr string) ItemDetails {
	result := ItemDetails{}
	if strings.TrimSpace(htmlStr) == "" {
		return result
	}

	// Normalize line breaks: convert <br> variants to \n, strip tags around labels, and remove extra spaces.
	normalized := htmlStr
	brRe := regexp.MustCompile(`(?i)<br\s*/?>`)
	normalized = brRe.ReplaceAllString(normalized, "\n")
	// Remove other tags but keep their inner text
	tagRe := regexp.MustCompile(`(?s)<[^>]+>`) // simple strip for remaining tags
	normalized = tagRe.ReplaceAllString(normalized, " ")
	// Decode HTML entities (e.g., &nbsp;)
	normalized = html.UnescapeString(normalized)
	normalized = strings.ReplaceAll(normalized, "\u00a0", " ") // nbsp
	normalized = strings.TrimSpace(compactSpaces(normalized))

	// Explode lines where multiple labels appear inline, e.g. "Bulletin ID: X Scope: Y ..."
	labels := []string{
		"Bulletin ID", "Scope", "Content Type", "Publication Date", "Updated Date", "Affected version", "Affected versions", "Resolution", "Description", "Workarounds", "References", "Acknowledgement",
	}
	labelUnion := strings.Join(labels, "|")
	inlineLabelRe := regexp.MustCompile("(?i)(?:^|\\s)(" + labelUnion + ")\\s*:")
	// Insert a newline before each label occurrence that is not at start
	normalized = inlineLabelRe.ReplaceAllStringFunc(normalized, func(m string) string {
		if strings.HasPrefix(m, " ") || strings.HasPrefix(m, "\t") {
			return "\n" + strings.TrimLeft(m, " \t")
		}
		return m
	})

	// Normalize headers that are at the start of a line but missing a colon
	for _, header := range []string{"Description", "References", "Workarounds", "Acknowledgement"} {
		reStart := regexp.MustCompile("(?im)^(\\s*" + header + ")\\s+")
		normalized = reStart.ReplaceAllString(normalized, "$1: ")
	}

	// Targeted split: If Description appears right after Publication Date / Updated Date on same line, break it
	pubThenDesc := regexp.MustCompile(`(?i)(Publication Date:\s*[^\n]+?)\s+(Description)\b`)
	normalized = pubThenDesc.ReplaceAllString(normalized, "$1\nDescription: ")
	updThenDesc := regexp.MustCompile(`(?i)(Updated Date:\s*[^\n]+?)\s+(Description)\b`)
	normalized = updThenDesc.ReplaceAllString(normalized, "$1\nDescription: ")

	// Block-based Description extraction: from line starting with Description: to the next header at line start
	descHeaderRe := regexp.MustCompile(`(?im)^\s*Description\s*:\s*`)
	if loc := descHeaderRe.FindStringIndex(normalized); loc != nil {
		content := normalized[loc[1]:]
		nextHeaderRe := regexp.MustCompile(`(?im)^\s*(Bulletin ID|Scope|Content Type|Publication Date|Updated Date|Affected version|Affected versions|Resolution|Workarounds|References|Acknowledgement)\s*:`)
		if nxt := nextHeaderRe.FindStringIndex(content); nxt != nil {
			content = content[:nxt[0]]
		}
		content = strings.TrimSpace(compactSpaces(content))
		content = strings.TrimLeft(content, ":- ")
		if content != "" {
			result.Description = content
		}
	}

	// Block-based Resolution extraction: from line starting with Resolution: to the next header at line start
	resHeaderRe := regexp.MustCompile(`(?im)^\s*Resolution\s*:\s*`)
	if loc := resHeaderRe.FindStringIndex(normalized); loc != nil {
		content := normalized[loc[1]:]
		nextHeaderRe := regexp.MustCompile(`(?im)^\s*(Bulletin ID|Scope|Content Type|Publication Date|Updated Date|Affected version|Affected versions|Description|Workarounds|References|Acknowledgement)\s*:`)
		if nxt := nextHeaderRe.FindStringIndex(content); nxt != nil {
			content = content[:nxt[0]]
		}
		content = strings.TrimSpace(compactSpaces(content))
		content = strings.TrimLeft(content, ":- ")
		if content != "" {
			result.Resolution = content
		}
	}

	lines := splitAndCleanLines(normalized)

	descIndex := -1
	refIndex := -1
	for i, line := range lines {
		key, val, ok := splitKeyVal(line)
		if !ok {
			continue
		}
		switch normalizeKey(key) {
		case "bulletin id":
			result.BulletinID = val
		case "scope":
			result.Scope = val
		case "content type":
			result.ContentType = val
		case "publication date":
			result.PublicationDate = val
		case "updated date":
			result.UpdatedDate = val
		case "affected version":
			result.AffectedVersions = val
		case "affected versions":
			result.AffectedVersions = val
		case "resolution":
			// Prefer block-extracted Resolution, otherwise take inline value
			if result.Resolution == "" {
				result.Resolution = val
			}
		case "workarounds":
			result.Workarounds = val
		case "description":
			descIndex = i
		case "references":
			refIndex = i
		case "acknowledgement":
			result.Acknowledgement = val
		}
	}

	// If we saw a Description header but block-extraction failed, join subsequent lines that are not key:value
	if result.Description == "" && descIndex >= 0 {
		var b strings.Builder
		for i := descIndex + 1; i < len(lines); i++ {
			k, _, ok := splitKeyVal(lines[i])
			if ok && isLikelyKey(k) {
				break
			}
			if b.Len() > 0 {
				b.WriteString(" ")
			}
			b.WriteString(lines[i])
		}
		desc := strings.TrimSpace(b.String())
		desc = strings.TrimLeft(desc, ":- ")
		if desc != "" {
			result.Description = desc
		}
	}

	// If we saw a References header, join subsequent lines until next key
	if refIndex >= 0 {
		var b strings.Builder
		// include any value text on same line
		_, val, ok := splitKeyVal(lines[refIndex])
		if ok && val != "" {
			b.WriteString(val)
		}
		for i := refIndex + 1; i < len(lines); i++ {
			k, vv, ok := splitKeyVal(lines[i])
			if ok && isLikelyKey(k) {
				break
			}
			if b.Len() > 0 {
				b.WriteString(" ")
			}
			if ok {
				b.WriteString(vv)
			} else {
				b.WriteString(lines[i])
			}
		}
		reflist := strings.TrimSpace(b.String())
		if reflist != "" {
			result.References = reflist
		}
	}

	// Extract CVE and GHSA identifiers from the normalized text
	cveRe := regexp.MustCompile(`(?i)CVE-\d{4}-\d{4,}`)
	ghsaRe := regexp.MustCompile(`(?i)GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}`)
	// use maps to dedupe
	cveSet := map[string]struct{}{}
	for _, m := range cveRe.FindAllString(normalized, -1) {
		u := strings.ToUpper(m)
		cveSet[u] = struct{}{}
	}
	if len(cveSet) > 0 {
		result.CVEs = make([]string, 0, len(cveSet))
		for id := range cveSet {
			result.CVEs = append(result.CVEs, id)
		}
		sort.Strings(result.CVEs)
	}
	advSet := map[string]struct{}{}
	for _, m := range ghsaRe.FindAllString(normalized, -1) {
		u := strings.ToUpper(m)
		advSet[u] = struct{}{}
	}
	if len(advSet) > 0 {
		result.Advisories = make([]string, 0, len(advSet))
		for id := range advSet {
			result.Advisories = append(result.Advisories, id)
		}
		sort.Strings(result.Advisories)
	}

	// Fallback: If description still empty, take the first non-empty non-key line.
	if result.Description == "" {
		for _, line := range lines {
			k, _, ok := splitKeyVal(line)
			if ok && isLikelyKey(k) {
				continue
			}
			if line != "" {
				clean := strings.TrimLeft(line, ":- ")
				result.Description = clean
				break
			}
		}
	}

	return result
}

func compactSpaces(s string) string {
	return strings.Join(strings.Fields(s), " ")
}

func splitAndCleanLines(s string) []string {
	raw := strings.Split(s, "\n")
	lines := make([]string, 0, len(raw))
	for _, l := range raw {
		l = strings.TrimSpace(compactSpaces(l))
		if l != "" {
			lines = append(lines, l)
		}
	}
	return lines
}

func splitKeyVal(line string) (string, string, bool) {
	idx := strings.Index(line, ":")
	if idx <= 0 {
		return "", "", false
	}
	key := strings.TrimSpace(line[:idx])
	val := strings.TrimSpace(line[idx+1:])
	return key, val, true
}

func normalizeKey(key string) string {
	k := strings.ToLower(strings.TrimSpace(key))
	k = strings.TrimSuffix(k, ":")
	k = strings.ReplaceAll(k, "\u00a0", " ")
	k = compactSpaces(k)
	return k
}

func isLikelyKey(key string) bool {
	switch normalizeKey(key) {
	case "bulletin id", "scope", "content type", "publication date", "updated date", "affected version", "affected versions", "resolution", "description", "workarounds", "references", "acknowledgement":
		return true
	default:
		return false
	}
}
