// Package feeds is the operator-facing RSS/Atom feed integration. Private
// trackers (TorrentDay, MyAnonamouse, etc.) publish per-user feeds whose
// URL embeds a long-lived passkey — the URL itself is the credential, so
// it lives in the age-encrypted secrets store and is loaded on demand.
//
// Feeds are deliberately NOT polled automatically. Tracker rate-limit
// risk and the operator's documented preference (see CLAUDE memory:
// feedback-no-auto-scraping) both say the same thing: pull only on
// explicit user action. The Feeds page in the SPA hits
// GET /api/feeds/{id}/items, which hits TD's t.rss endpoint once per
// click, parses, and returns.
package feeds

import (
	"context"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"time"
)

// Item is the normalized shape returned to the API consumer. Fields not
// present in the source RSS arrive as zero values; downstream UI handles
// the optional rendering.
type Item struct {
	GUID        string    `json:"guid,omitempty"`
	Title       string    `json:"title"`
	Link        string    `json:"link"`             // the .torrent download URL
	Category    string    `json:"category,omitempty"`
	PublishedAt time.Time `json:"published_at,omitempty"`
	SizeBytes   int64     `json:"size_bytes,omitempty"`
	Seeders     *int64    `json:"seeders,omitempty"`
	Leechers    *int64    `json:"leechers,omitempty"`
	Comments    *int64    `json:"comments,omitempty"`
	Description string    `json:"description,omitempty"`
}

// Channel is the wrapper returned to the client. RetrievedAt + Source URL
// (with passkey redacted for the response) help the operator confirm what
// they're looking at without having to inspect server-side logs.
type Channel struct {
	Title       string    `json:"title,omitempty"`
	Description string    `json:"description,omitempty"`
	Link        string    `json:"link,omitempty"`
	RetrievedAt time.Time `json:"retrieved_at"`
	Items       []Item    `json:"items"`
}

// rss / atom decoding shapes. Keep them tolerant — different trackers emit
// slightly different markup and TD's t.rss isn't guaranteed to stay
// schema-stable across versions.

type rssFeed struct {
	XMLName xml.Name   `xml:"rss"`
	Channel rssChannel `xml:"channel"`
}

type rssChannel struct {
	Title       string    `xml:"title"`
	Description string    `xml:"description"`
	Link        string    `xml:"link"`
	Items       []rssItem `xml:"item"`
}

type rssItem struct {
	Title       string      `xml:"title"`
	Link        string      `xml:"link"`
	GUID        string      `xml:"guid"`
	Description string      `xml:"description"`
	Category    string      `xml:"category"`
	PubDate     string      `xml:"pubDate"`
	Enclosure   rssEnclosure `xml:"enclosure"`
}

type rssEnclosure struct {
	URL    string `xml:"url,attr"`
	Length int64  `xml:"length,attr"`
	Type   string `xml:"type,attr"`
}

var (
	// "Size: 12.34 GB" / "Size: 12,345 MB" / "Size: 12.34GiB"
	reSize = regexp.MustCompile(`(?i)size[^0-9]{0,8}([0-9][0-9,\.]*)\s*(B|KB|KiB|MB|MiB|GB|GiB|TB|TiB)`)
	// "Seeders: 12" / "Seeds: 12"
	reSeeders = regexp.MustCompile(`(?i)seed(?:ers|s)[^0-9]{0,8}([0-9]+)`)
	// "Leechers: 3" / "Leech: 3"
	reLeechers = regexp.MustCompile(`(?i)leech(?:ers|s)?[^0-9]{0,8}([0-9]+)`)
	// "Comments: 0"
	reComments = regexp.MustCompile(`(?i)comments?[^0-9]{0,8}([0-9]+)`)
)

// Fetch downloads `url`, parses the response as RSS, and returns a
// normalized Channel. Uses the supplied http.Client (so the caller can
// inject timeouts / cookie jars / proxies as needed). The 16 MiB cap is
// well above any plausible feed payload — TD's full t.rss with all
// categories is well under 1 MiB.
func Fetch(ctx context.Context, client *http.Client, url string) (*Channel, error) {
	if client == nil {
		client = http.DefaultClient
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "application/rss+xml, application/xml, text/xml, */*")
	req.Header.Set("User-Agent", "command-center/1.0 (+https://adampowell.pro/command-center/)")

	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("feeds: fetch: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		preview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
		return nil, fmt.Errorf("feeds: status %d: %s", resp.StatusCode, string(preview))
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
	if err != nil {
		return nil, fmt.Errorf("feeds: read body: %w", err)
	}
	return Parse(body)
}

// Parse decodes a raw RSS payload. Exported so it's unit-testable without
// a network round-trip.
func Parse(body []byte) (*Channel, error) {
	if len(body) == 0 {
		return nil, errors.New("feeds: empty body")
	}
	var raw rssFeed
	dec := xml.NewDecoder(strings.NewReader(string(body)))
	dec.Strict = false
	dec.CharsetReader = identityCharsetReader
	if err := dec.Decode(&raw); err != nil {
		return nil, fmt.Errorf("feeds: parse rss: %w", err)
	}

	out := &Channel{
		Title:       strings.TrimSpace(raw.Channel.Title),
		Description: strings.TrimSpace(raw.Channel.Description),
		Link:        strings.TrimSpace(raw.Channel.Link),
		RetrievedAt: time.Now().UTC(),
		Items:       make([]Item, 0, len(raw.Channel.Items)),
	}
	for _, ri := range raw.Channel.Items {
		it := Item{
			Title:       strings.TrimSpace(ri.Title),
			Link:        strings.TrimSpace(ri.Link),
			GUID:        strings.TrimSpace(ri.GUID),
			Category:    strings.TrimSpace(ri.Category),
			Description: strings.TrimSpace(ri.Description),
		}
		if it.Link == "" && ri.Enclosure.URL != "" {
			it.Link = ri.Enclosure.URL
		}
		if ri.Enclosure.Length > 0 {
			it.SizeBytes = ri.Enclosure.Length
		}
		if ri.PubDate != "" {
			it.PublishedAt = parseRFC822(ri.PubDate)
		}
		// Description is the catch-all in TD-style RSS — extract size +
		// seed counts from it if the structured fields above didn't fire.
		desc := it.Description
		if desc != "" {
			if it.SizeBytes == 0 {
				if m := reSize.FindStringSubmatch(desc); len(m) == 3 {
					if b, ok := parseByteSize(m[1], m[2]); ok {
						it.SizeBytes = b
					}
				}
			}
			if m := reSeeders.FindStringSubmatch(desc); len(m) == 2 {
				if n, err := strconv.ParseInt(m[1], 10, 64); err == nil {
					it.Seeders = &n
				}
			}
			if m := reLeechers.FindStringSubmatch(desc); len(m) == 2 {
				if n, err := strconv.ParseInt(m[1], 10, 64); err == nil {
					it.Leechers = &n
				}
			}
			if m := reComments.FindStringSubmatch(desc); len(m) == 2 {
				if n, err := strconv.ParseInt(m[1], 10, 64); err == nil {
					it.Comments = &n
				}
			}
		}
		out.Items = append(out.Items, it)
	}
	return out, nil
}

// parseRFC822 handles the assorted date formats trackers emit in pubDate.
// Returns zero time on failure (caller renders as "unknown").
func parseRFC822(s string) time.Time {
	for _, layout := range []string{
		time.RFC1123Z, time.RFC1123, time.RFC822Z, time.RFC822,
		"Mon, 02 Jan 2006 15:04:05 GMT",
		"Mon, 02 Jan 2006 15:04:05 MST",
		"2006-01-02T15:04:05Z07:00",
		"2006-01-02 15:04:05",
	} {
		if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
			return t.UTC()
		}
	}
	return time.Time{}
}

// parseByteSize: "1.23", "GB" → bytes. Uses 1024 across all units to
// match what tracker UIs typically display (which is what the operator
// sees). Same conventions as the qbit + td-adapter parsers in the repo.
func parseByteSize(num, unit string) (int64, bool) {
	num = strings.ReplaceAll(num, ",", "")
	f, err := strconv.ParseFloat(num, 64)
	if err != nil {
		return 0, false
	}
	var mul float64
	switch strings.ToUpper(unit) {
	case "B":
		mul = 1
	case "KB", "KIB":
		mul = 1024
	case "MB", "MIB":
		mul = 1024 * 1024
	case "GB", "GIB":
		mul = 1024 * 1024 * 1024
	case "TB", "TIB":
		mul = 1024 * 1024 * 1024 * 1024
	default:
		return 0, false
	}
	return int64(f * mul), true
}

// identityCharsetReader makes encoding/xml accept declared charsets
// (e.g. utf-8, iso-8859-1) without pulling in golang.org/x/net/html for
// the actual decoder. RSS payloads are nearly always UTF-8 in practice.
func identityCharsetReader(_ string, r io.Reader) (io.Reader, error) {
	return r, nil
}
