// Package bazarr provides the slice of Bazarr Memby uses to fetch a subtitle a title does
// not have yet.
//
// Bazarr is the subtitle companion to Sonarr and Radarr, which the gateway already talks
// to, and it is the reason this feature needs no storage of its own: Bazarr writes the
// subtitle file beside the media file, so Emby finds it on the next refresh and the track
// arrives through the same PlaybackInfo path as every other subtitle. Memby never holds a
// subtitle, never serves one, and never learns a provider's credentials.
//
// Two things about the API are worth stating because they are not obvious and both would
// otherwise be discovered as a bug. Bazarr keys everything on the *arr's id, not its own:
// a movie is a `radarrid` and an episode is an `episodeid` from Sonarr, which is why
// resolving an Emby item to one of them is a real step rather than a lookup. And the
// manual-search result rows are opaque — the `subtitle` field is a provider-specific token
// that has to be handed back verbatim on the download call, so nothing here parses or
// reconstructs it.
package bazarr

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"
)

type Client struct {
	baseURL string
	apiKey  string
	http    *http.Client
}

// Movie is one row of Bazarr's movie list. Only the fields Memby matches an Emby item
// against, plus the id it needs afterwards, are decoded.
type Movie struct {
	RadarrID int    `json:"radarrId"`
	Title    string `json:"title"`
	Year     string `json:"year"`
	Path     string `json:"path"`
}

type Series struct {
	SonarrSeriesID int    `json:"sonarrSeriesId"`
	Title          string `json:"title"`
	Year           string `json:"year"`
}

type Episode struct {
	SonarrEpisodeID int    `json:"sonarrEpisodeId"`
	SonarrSeriesID  int    `json:"sonarrSeriesId"`
	Season          int    `json:"season"`
	Episode         int    `json:"episode"`
	Title           string `json:"title"`
}

// Subtitle is one candidate from a manual search.
//
// Score is Bazarr's own 0–100 confidence that the subtitle matches this exact release, and
// it is the only thing on the row a viewer can sensibly choose by — provider names and
// release strings mean nothing on a television. Token is the opaque value the download
// call needs; it is never interpreted here.
type Subtitle struct {
	Language        string   `json:"language"`
	Forced          bool     `json:"forced"`
	HearingImpaired bool     `json:"hearing_impaired"`
	Provider        string   `json:"provider"`
	Token           string   `json:"subtitle"`
	Score           int      `json:"score"`
	ReleaseInfo     []string `json:"release_info"`
	Uploader        string   `json:"uploader"`
	OriginalFormat  bool     `json:"original_format"`
}

// hearingImpaired and forced arrive from Bazarr as either a bool or one of its
// "True"/"False"/"" strings depending on the endpoint and version. Decoding them as
// json.RawMessage and folding here is what stops a version bump from silently turning
// every result into a non-forced one.
func (s *Subtitle) UnmarshalJSON(data []byte) error {
	type raw struct {
		Language        json.RawMessage `json:"language"`
		Forced          json.RawMessage `json:"forced"`
		HearingImpaired json.RawMessage `json:"hearing_impaired"`
		Provider        string          `json:"provider"`
		Token           string          `json:"subtitle"`
		Score           int             `json:"score"`
		ReleaseInfo     []string        `json:"release_info"`
		Uploader        string          `json:"uploader"`
		OriginalFormat  json.RawMessage `json:"original_format"`
	}
	var decoded raw
	if err := json.Unmarshal(data, &decoded); err != nil {
		return err
	}
	s.Language = flexibleLanguage(decoded.Language)
	s.Forced = flexibleBool(decoded.Forced)
	s.HearingImpaired = flexibleBool(decoded.HearingImpaired)
	s.Provider = decoded.Provider
	s.Token = decoded.Token
	s.Score = decoded.Score
	s.ReleaseInfo = decoded.ReleaseInfo
	s.Uploader = decoded.Uploader
	s.OriginalFormat = flexibleBool(decoded.OriginalFormat)
	return nil
}

// flexibleBool accepts true, "True", "true" and "1" as true, and treats anything else —
// including an absent field — as false.
func flexibleBool(raw json.RawMessage) bool {
	if len(raw) == 0 {
		return false
	}
	var asBool bool
	if json.Unmarshal(raw, &asBool) == nil {
		return asBool
	}
	var asString string
	if json.Unmarshal(raw, &asString) != nil {
		return false
	}
	switch strings.ToLower(strings.TrimSpace(asString)) {
	case "true", "1", "yes":
		return true
	default:
		return false
	}
}

// flexibleLanguage takes the code out of either shape Bazarr uses for a language: a plain
// string, or an object carrying `code2`/`code3` and a name.
func flexibleLanguage(raw json.RawMessage) string {
	if len(raw) == 0 {
		return ""
	}
	var asString string
	if json.Unmarshal(raw, &asString) == nil {
		return strings.TrimSpace(asString)
	}
	var asObject struct {
		Code2 string `json:"code2"`
		Code3 string `json:"code3"`
		Name  string `json:"name"`
	}
	if json.Unmarshal(raw, &asObject) != nil {
		return ""
	}
	for _, candidate := range []string{asObject.Code2, asObject.Code3, asObject.Name} {
		if value := strings.TrimSpace(candidate); value != "" {
			return value
		}
	}
	return ""
}

type APIError struct {
	StatusCode int
	Body       string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("bazarr: status %d: %s", e.StatusCode, e.Body)
}

func New(baseURL, apiKey string, timeout time.Duration) *Client {
	return &Client{
		baseURL: strings.TrimRight(baseURL, "/"),
		apiKey:  apiKey,
		http: &http.Client{
			Timeout: timeout,
			Transport: &http.Transport{
				MaxIdleConns:        10,
				MaxIdleConnsPerHost: 5,
				IdleConnTimeout:     90 * time.Second,
			},
		},
	}
}

// Movies returns every movie Bazarr manages. Bazarr pages this endpoint, and asking for
// everything in one call is deliberate: the result is cached by the caller for minutes at
// a time, and walking pages would multiply that one cold request by the size of a library.
func (c *Client) Movies(ctx context.Context) ([]Movie, error) {
	var payload struct {
		Data []Movie `json:"data"`
	}
	if err := c.get(ctx, "/api/movies", url.Values{"length": {"-1"}}, &payload); err != nil {
		return nil, err
	}
	return payload.Data, nil
}

func (c *Client) Series(ctx context.Context) ([]Series, error) {
	var payload struct {
		Data []Series `json:"data"`
	}
	if err := c.get(ctx, "/api/series", url.Values{"length": {"-1"}}, &payload); err != nil {
		return nil, err
	}
	return payload.Data, nil
}

func (c *Client) Episodes(ctx context.Context, seriesID int) ([]Episode, error) {
	var payload struct {
		Data []Episode `json:"data"`
	}
	params := url.Values{"seriesid[]": {strconv.Itoa(seriesID)}}
	if err := c.get(ctx, "/api/episodes", params, &payload); err != nil {
		return nil, err
	}
	return payload.Data, nil
}

// SearchMovie runs Bazarr's manual search for a film. It is a live provider query, so it
// is slow — seconds, not milliseconds — which is why the television is told to expect a
// wait rather than being left with a spinner that looks stuck.
func (c *Client) SearchMovie(ctx context.Context, radarrID int) ([]Subtitle, error) {
	return c.search(ctx, "/api/providers/movies", url.Values{"radarrid": {strconv.Itoa(radarrID)}})
}

func (c *Client) SearchEpisode(ctx context.Context, episodeID int) ([]Subtitle, error) {
	return c.search(ctx, "/api/providers/episodes", url.Values{"episodeid": {strconv.Itoa(episodeID)}})
}

func (c *Client) search(ctx context.Context, path string, params url.Values) ([]Subtitle, error) {
	var subtitles []Subtitle
	if err := c.get(ctx, path, params, &subtitles); err != nil {
		return nil, err
	}
	return subtitles, nil
}

// DownloadMovie tells Bazarr to fetch one of the search results and write it beside the
// film. The token is handed back exactly as it arrived.
func (c *Client) DownloadMovie(ctx context.Context, radarrID int, subtitle Subtitle) error {
	return c.download(ctx, "/api/providers/movies", url.Values{
		"radarrid": {strconv.Itoa(radarrID)},
	}, subtitle)
}

func (c *Client) DownloadEpisode(ctx context.Context, seriesID, episodeID int, subtitle Subtitle) error {
	return c.download(ctx, "/api/providers/episodes", url.Values{
		"seriesid":  {strconv.Itoa(seriesID)},
		"episodeid": {strconv.Itoa(episodeID)},
	}, subtitle)
}

func (c *Client) download(ctx context.Context, path string, target url.Values, subtitle Subtitle) error {
	form := url.Values{}
	for key, values := range target {
		form[key] = values
	}
	form.Set("language", subtitle.Language)
	form.Set("hi", strconv.FormatBool(subtitle.HearingImpaired))
	form.Set("forced", strconv.FormatBool(subtitle.Forced))
	form.Set("original_format", strconv.FormatBool(subtitle.OriginalFormat))
	form.Set("provider", subtitle.Provider)
	form.Set("subtitle", subtitle.Token)

	req, err := http.NewRequestWithContext(
		ctx, http.MethodPost, c.baseURL+path, strings.NewReader(form.Encode()),
	)
	if err != nil {
		return err
	}
	req.Header.Set("X-API-KEY", c.apiKey)
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Accept", "application/json")
	// Bazarr answers a successful download with an empty body, so there is nothing to
	// decode — only a status to check.
	return c.do(req, nil)
}

// Ping is the reachability check the admin console and startup use. Bazarr's status
// endpoint needs no arguments and returns quickly even when providers are slow.
func (c *Client) Ping(ctx context.Context) error {
	var payload struct {
		Data map[string]any `json:"data"`
	}
	return c.get(ctx, "/api/system/status", nil, &payload)
}

func (c *Client) get(ctx context.Context, path string, params url.Values, out any) error {
	endpoint := c.baseURL + path
	if len(params) > 0 {
		endpoint += "?" + params.Encode()
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return err
	}
	req.Header.Set("X-API-KEY", c.apiKey)
	req.Header.Set("Accept", "application/json")
	return c.do(req, out)
}

func (c *Client) do(req *http.Request, out any) error {
	resp, err := c.http.Do(req)
	if err != nil {
		return fmt.Errorf("bazarr: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
		return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
	}
	if out == nil {
		_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
		return nil
	}
	if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
		return fmt.Errorf("bazarr: decode response: %w", err)
	}
	return nil
}
