package download

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/url"
	"strconv"
	"strings"
	"time"
)

// Prowlarr is the one provider that cannot finish a job by itself: it
// searches dozens of indexers and hands back magnet links and NZB URLs,
// which some other client has to actually fetch.  That is the whole
// reason the role interfaces are separate — Prowlarr implements
// Searcher and nothing else, and the pipeline pairs its results with
// whichever enabled transport handles the protocol.
//
// Its search results also carry no file list.  A torrent is one opaque
// blob until it is fetched, so match scoring here has only the release
// title to work with, and candidates are marked accordingly rather than
// pretending to know what is inside.

// Prowlarr provider errors.
var (
	// ErrProwlarrUnreachable means the instance did not answer.
	ErrProwlarrUnreachable = errors.New("prowlarr is unreachable")

	// ErrProwlarrAuth means the API key was rejected.
	ErrProwlarrAuth = errors.New("prowlarr rejected the API key")

	// ErrProwlarrNoIndexers means nothing is configured to search.
	ErrProwlarrNoIndexers = errors.New("prowlarr has no enabled indexers")
)

// prowlarrHTTPTimeout bounds one API call.  Indexer fan-out is slow, so
// this is longer than the other adapters'.
const prowlarrHTTPTimeout = 45 * time.Second

// prowlarrMusicCategory is Newznab's music category.  Searching without
// it returns every match across film and software too.
const prowlarrMusicCategory = "3000"

// prowlarrMaxResults caps how many results are turned into candidates.
const prowlarrMaxResults = 40

func init() {
	Register(
		Descriptor{
			Kind: KindProwlarr,
			Name: "Prowlarr",
			Summary: "Search many torrent and usenet indexers at once. " +
				"Needs a download client (qBittorrent or SABnzbd) to fetch results.",
			RequiresExternal: "Prowlarr",
			Caps: Caps{
				CanSearch: true,
			},
			Fields: []Field{
				{
					Key:         "url",
					Label:       "Prowlarr URL",
					Placeholder: "http://localhost:9696",
					Required:    true,
					Default:     "http://localhost:9696",
				},
				{
					Key:      "apiKey",
					Label:    "API key",
					Secret:   true,
					Required: true,
					Help:     "Prowlarr → Settings → General → API Key.",
				},
				{
					Key:   "indexerIds",
					Label: "Indexer IDs",
					Help: "Comma-separated numeric IDs to restrict the search to. " +
						"Leave blank to search all enabled indexers.",
				},
				{
					Key:   "minSeeders",
					Label: "Minimum seeders",
					Help: "Torrent results below this are hidden. " +
						"Defaults to 1.",
					Default: "1",
				},
			},
		},
		newProwlarr,
	)
}

// prowlarr is the Prowlarr search provider.
type prowlarr struct {
	info   ProviderInfo
	logger *slog.Logger
	client *apiClient

	indexerIDs []string
	minSeeders int
}

// newProwlarr builds the provider from config.
func newProwlarr(
	cfg Config,
	secrets SecretLookup,
	logger *slog.Logger,
) (Provider, error) {
	base := strings.TrimRight(cfg.Setting("url", ""), "/")
	if base == "" {
		return nil, fmt.Errorf("%w: Prowlarr URL is required", ErrNotConfigured)
	}

	apiKey := ""

	if secrets != nil {
		key, err := secrets("apiKey")
		if err != nil {
			return nil, fmt.Errorf("%w: no API key stored", ErrNotConfigured)
		}

		apiKey = key
	}

	minSeeders, err := strconv.Atoi(cfg.Setting("minSeeders", "1"))
	if err != nil {
		minSeeders = 1
	}

	var indexers []string

	for _, id := range strings.Split(cfg.Setting("indexerIds", ""), ",") {
		if trimmed := strings.TrimSpace(id); trimmed != "" {
			indexers = append(indexers, trimmed)
		}
	}

	return &prowlarr{
		info: ProviderInfo{
			ID:       cfg.ID,
			Kind:     KindProwlarr,
			Name:     cfg.Name,
			Enabled:  cfg.Enabled,
			Priority: cfg.Priority,
			Caps:     Caps{CanSearch: true},
		},
		logger: logger.With("provider", "prowlarr"),
		client: newAPIClient(
			base, "X-Api-Key", apiKey, prowlarrHTTPTimeout,
			ErrProwlarrUnreachable, ErrProwlarrAuth,
		),
		indexerIDs: indexers,
		minSeeders: minSeeders,
	}, nil
}

// Info returns the provider's identity.
func (p *prowlarr) Info() ProviderInfo {
	return p.info
}

// Close is a no-op.
func (p *prowlarr) Close() error {
	return nil
}

// Check verifies the instance answers and has something to search.
func (p *prowlarr) Check(ctx context.Context) error {
	var status struct {
		Version string `json:"version"`
	}

	if err := p.client.get(ctx, "/api/v1/system/status", &status); err != nil {
		return err
	}

	var indexers []struct {
		ID     int  `json:"id"`
		Enable bool `json:"enable"`
	}

	if err := p.client.get(ctx, "/api/v1/indexer", &indexers); err != nil {
		return err
	}

	for _, i := range indexers {
		if i.Enable {
			return nil
		}
	}

	return ErrProwlarrNoIndexers
}

// prowlarrResult is one indexer hit.
type prowlarrResult struct {
	GUID        string `json:"guid"`
	Title       string `json:"title"`
	Indexer     string `json:"indexer"`
	Size        int64  `json:"size"`
	Seeders     int    `json:"seeders"`
	Leechers    int    `json:"leechers"`
	Protocol    string `json:"protocol"` // "torrent" or "usenet"
	DownloadURL string `json:"downloadUrl"`
	MagnetURL   string `json:"magnetUrl"`
	InfoHash    string `json:"infoHash"`
}

// Search queries every configured indexer through Prowlarr.
func (p *prowlarr) Search(
	ctx context.Context,
	dl Download,
) ([]Candidate, error) {
	query := url.Values{}
	query.Set("query", dl.SearchText())
	query.Set("categories", prowlarrMusicCategory)
	query.Set("type", "search")

	for _, id := range p.indexerIDs {
		query.Add("indexerIds", id)
	}

	var results []prowlarrResult

	endpoint := "/api/v1/search?" + query.Encode()

	if err := p.client.get(ctx, endpoint, &results); err != nil {
		return nil, err
	}

	out := make([]Candidate, 0, len(results))

	for _, r := range results {
		if len(out) >= prowlarrMaxResults {
			break
		}

		protocol := protocolFor(r.Protocol)
		if protocol == ProtocolDirect {
			continue
		}

		// A torrent with no seeders will never finish.  Offering it
		// wastes the user's pick on something that cannot complete.
		if protocol == ProtocolTorrent && r.Seeders < p.minSeeders {
			continue
		}

		link := r.MagnetURL
		if link == "" {
			link = r.DownloadURL
		}

		if link == "" {
			continue
		}

		out = append(out, Candidate{
			ID:       "prowlarr:" + r.GUID,
			Kind:     KindProwlarr,
			Protocol: protocol,
			Title:    r.Title,
			Origin:   r.Indexer,
			// Indexer results are opaque before they are fetched: there
			// is no file list, so no per-file scoring is possible and
			// the ranker works from the release title alone.
			Files:     nil,
			TotalSize: r.Size,
			Health:    swarmHealth(protocol, r.Seeders),
			Payload: map[string]string{
				"link":     link,
				"indexer":  r.Indexer,
				"infoHash": r.InfoHash,
			},
		})
	}

	return out, nil
}

// protocolFor maps Prowlarr's protocol string onto ours.
func protocolFor(s string) Protocol {
	switch strings.ToLower(s) {
	case "torrent":
		return ProtocolTorrent
	case "usenet":
		return ProtocolUsenet
	default:
		return ProtocolDirect
	}
}

// swarmHealth scores availability, in 0..1.  Usenet has no swarm: a
// retained article either downloads at full speed or is gone, so it
// gets a flat, confident score.
func swarmHealth(protocol Protocol, seeders int) float64 {
	if protocol == ProtocolUsenet {
		return 0.85
	}

	// Seeder counts have sharply diminishing returns — the difference
	// between 1 and 10 is enormous, between 100 and 500 irrelevant.
	switch {
	case seeders <= 0:
		return 0.05
	case seeders == 1:
		return 0.3
	case seeders < 5:
		return 0.5
	case seeders < 20:
		return 0.75
	case seeders < 100:
		return 0.9
	default:
		return 1.0
	}
}
