// Package opensubtitles provides the slice of opensubtitles.com Memby uses to fetch a
// subtitle a title does not have.
//
// It is the second subtitle provider and it is not shaped like the first. Bazarr's whole
// appeal is that it writes the file beside the media file, so the gateway asks and then
// forgets — Emby finds the result and the track arrives down the ordinary PlaybackInfo
// path. OpenSubtitles hands back bytes, and the gateway has no access to the media
// directory, so a file fetched here is stored by the gateway and served back as a sidecar.
// That difference is worth stating because everything else about the two providers is the
// same shape, and the storage is the only reason `downloaded_subtitles` exists.
//
// Three things about the API are not obvious and each would otherwise be found as a bug:
//
//   - Identity is an id, not a title. Searching takes an imdb or tmdb id, which is a far
//     better match than the title-and-year guessing the Bazarr path is stuck with — and
//     Memby already has those ids, because the library import asks Emby for ProviderIds
//     so external ratings can be looked up.
//   - The search result is not the file. A row carries a `file_id`, and turning that into
//     bytes is a second call to /download which returns a short-lived link. It is the
//     /download call that spends the account's daily allowance, never the search.
//   - /download wants a logged-in token in practice. An API key alone is accepted but the
//     anonymous allowance is a handful of files a day, which in front of a television
//     reads as the feature being broken. Credentials are optional here and the token is
//     cached, because logging in per download would spend a different quota instead.
package opensubtitles

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

// DefaultBaseURL is the REST API's home. It is a field on the client so the tests can
// point at a fake, never so an operator can redirect credentials somewhere else.
const DefaultBaseURL = "https://api.opensubtitles.com/api/v1"

// maxSubtitleBytes bounds what will be read from a download link. A subtitle is tens of
// kilobytes; anything approaching this is not a subtitle, and the gateway stores what it
// fetches, so an unbounded read here would be an unbounded row in Postgres.
const maxSubtitleBytes = 4 << 20

// tokenLifetime is how long a login token is reused for. OpenSubtitles issues them for
// about a day; renewing well inside that costs one request and avoids the case nobody
// tests, which is the token expiring in the middle of somebody's film.
const tokenLifetime = 12 * time.Hour

type Client struct {
	baseURL   string
	apiKey    string
	userAgent string
	username  string
	password  string
	http      *http.Client

	// The login token is shared by every viewer in the house, because the account is the
	// household's rather than anybody's. The mutex is held across the login request so a
	// launcher full of televisions cannot log in six times at once.
	mu          sync.Mutex
	token       string
	tokenExpiry time.Time
}

// Subtitle is one candidate from a search.
//
// FileID is what /download takes and it is the only field that has to survive the round
// trip through a television. Rating and Downloads are the two numbers a viewer could
// sensibly choose by, and they are folded into a single Score by the caller so a row from
// here reads the same as a row from Bazarr.
type Subtitle struct {
	FileID          int
	SubtitleID      string
	Language        string
	Release         string
	FileName        string
	Format          string
	Forced          bool
	HearingImpaired bool
	MachineOnly     bool
	FromTrusted     bool
	Downloads       int
	Rating          float64
}

// Query is what identifies a title. Exactly one of IMDBID, TMDBID and Query is normally
// set; for an episode, ParentIMDBID with Season and Episode is the reliable shape, since
// far more shows carry an id on the series than on every episode.
type Query struct {
	IMDBID       string
	TMDBID       string
	ParentIMDBID string
	ParentTMDBID string
	Query        string
	Season       int
	Episode      int
	Languages    []string
	// Type narrows the search to "movie" or "episode". It is worth sending: a query by
	// title alone otherwise returns a film and the show named after it together.
	Type string
}

type APIError struct {
	StatusCode int
	Body       string
}

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

// QuotaError is the one failure worth telling a viewer about in its own words. Everything
// else is "the provider did not answer"; this one is "you have used today's downloads",
// which is not something pressing the button again will fix.
type QuotaError struct {
	ResetTime string
}

func (e *QuotaError) Error() string {
	if e.ResetTime != "" {
		return "opensubtitles: download quota exhausted, resets in " + e.ResetTime
	}
	return "opensubtitles: download quota exhausted"
}

func New(apiKey, userAgent, username, password string, timeout time.Duration) *Client {
	return &Client{
		baseURL:   DefaultBaseURL,
		apiKey:    strings.TrimSpace(apiKey),
		userAgent: strings.TrimSpace(userAgent),
		username:  strings.TrimSpace(username),
		password:  password,
		http: &http.Client{
			Timeout: timeout,
			Transport: &http.Transport{
				MaxIdleConns:        10,
				MaxIdleConnsPerHost: 5,
				IdleConnTimeout:     90 * time.Second,
			},
		},
	}
}

// SetBaseURL points the client somewhere else. It exists for the tests.
func (c *Client) SetBaseURL(base string) {
	c.baseURL = strings.TrimRight(base, "/")
}

// HasAccount reports whether a download will be made against the household's own
// allowance rather than the anonymous one. The console shows it, because the difference
// is the difference between a working feature and one that stops after a few files.
func (c *Client) HasAccount() bool {
	return c.username != "" && c.password != ""
}

// Search asks the providers what exists for one title.
//
// A search costs nothing against the download allowance, which is why the television is
// allowed to run one per press. An empty result is an ordinary answer.
func (c *Client) Search(ctx context.Context, query Query) ([]Subtitle, error) {
	params := searchParams(query)
	if len(params) == 0 {
		return nil, fmt.Errorf("opensubtitles: nothing to search by")
	}
	var payload struct {
		Data []struct {
			Attributes struct {
				SubtitleID        string  `json:"subtitle_id"`
				Language          string  `json:"language"`
				DownloadCount     int     `json:"download_count"`
				HearingImpaired   bool    `json:"hearing_impaired"`
				ForeignPartsOnly  bool    `json:"foreign_parts_only"`
				FromTrusted       bool    `json:"from_trusted"`
				AITranslated      bool    `json:"ai_translated"`
				MachineTranslated bool    `json:"machine_translated"`
				Ratings           float64 `json:"ratings"`
				Release           string  `json:"release"`
				Files             []struct {
					FileID   int    `json:"file_id"`
					FileName string `json:"file_name"`
				} `json:"files"`
			} `json:"attributes"`
		} `json:"data"`
	}
	if err := c.get(ctx, "/subtitles", params, &payload); err != nil {
		return nil, err
	}
	out := make([]Subtitle, 0, len(payload.Data))
	for _, row := range payload.Data {
		attributes := row.Attributes
		// A row with no file is not a candidate: there is nothing to hand to /download,
		// and a chooseable row that cannot be fetched is worse than one fewer row.
		if len(attributes.Files) == 0 || attributes.Files[0].FileID == 0 {
			continue
		}
		file := attributes.Files[0]
		out = append(out, Subtitle{
			FileID:          file.FileID,
			SubtitleID:      attributes.SubtitleID,
			Language:        strings.TrimSpace(attributes.Language),
			Release:         strings.TrimSpace(attributes.Release),
			FileName:        strings.TrimSpace(file.FileName),
			Format:          formatFromName(file.FileName),
			Forced:          attributes.ForeignPartsOnly,
			HearingImpaired: attributes.HearingImpaired,
			MachineOnly:     attributes.AITranslated || attributes.MachineTranslated,
			FromTrusted:     attributes.FromTrusted,
			Downloads:       attributes.DownloadCount,
			Rating:          attributes.Ratings,
		})
	}
	return out, nil
}

// CanSearch reports whether a query identifies a title well enough to be worth sending.
// It is the same rule searchParams applies, exported so a caller can decide not to offer
// this provider for a title rather than sending a request that cannot answer.
func CanSearch(query Query) bool {
	return len(searchParams(query)) > 0
}

// searchParams is pure so the shape of a query can be pinned by a test. The parent ids are
// only sent for an episode: on a film they mean nothing, and sending both an id and a
// season number is how a search comes back empty for a title that plainly exists.
func searchParams(query Query) url.Values {
	params := url.Values{}
	episode := query.Season > 0 || query.Episode > 0
	switch {
	case strings.TrimSpace(query.IMDBID) != "":
		params.Set("imdb_id", trimIMDB(query.IMDBID))
	case strings.TrimSpace(query.TMDBID) != "":
		params.Set("tmdb_id", strings.TrimSpace(query.TMDBID))
	case episode && strings.TrimSpace(query.ParentIMDBID) != "":
		params.Set("parent_imdb_id", trimIMDB(query.ParentIMDBID))
	case episode && strings.TrimSpace(query.ParentTMDBID) != "":
		params.Set("parent_tmdb_id", strings.TrimSpace(query.ParentTMDBID))
	case strings.TrimSpace(query.Query) != "":
		params.Set("query", strings.TrimSpace(query.Query))
	default:
		return nil
	}
	if episode {
		if query.Season >= 0 && (query.Season > 0 || query.Episode > 0) {
			params.Set("season_number", strconv.Itoa(query.Season))
		}
		if query.Episode > 0 {
			params.Set("episode_number", strconv.Itoa(query.Episode))
		}
	}
	if languages := joinLanguages(query.Languages); languages != "" {
		params.Set("languages", languages)
	}
	if kind := strings.TrimSpace(query.Type); kind != "" {
		params.Set("type", kind)
	}
	return params
}

// joinLanguages normalises the language list the API wants: lower case, comma separated,
// sorted, and deduplicated. It is fussy about this — an unsorted list is rejected — which
// is exactly the kind of thing that fails once in production and never in a review.
func joinLanguages(languages []string) string {
	seen := map[string]bool{}
	values := make([]string, 0, len(languages))
	for _, language := range languages {
		language = strings.ToLower(strings.TrimSpace(language))
		if language == "" || seen[language] {
			continue
		}
		seen[language] = true
		values = append(values, language)
	}
	for i := 1; i < len(values); i++ {
		for j := i; j > 0 && values[j] < values[j-1]; j-- {
			values[j], values[j-1] = values[j-1], values[j]
		}
	}
	return strings.Join(values, ",")
}

func trimIMDB(value string) string {
	return strings.TrimPrefix(strings.TrimSpace(value), "tt")
}

func formatFromName(name string) string {
	if index := strings.LastIndex(name, "."); index >= 0 && index < len(name)-1 {
		extension := strings.ToLower(name[index+1:])
		if extension == "srt" || extension == "vtt" || extension == "ass" || extension == "ssa" {
			return extension
		}
	}
	return "srt"
}

// Download turns a file id into bytes.
//
// It is two requests — a link, then the file — and it is the call that spends the
// account's allowance, so it is never made speculatively and never on the playback path.
func (c *Client) Download(ctx context.Context, fileID int) (name string, content []byte, err error) {
	body, err := json.Marshal(map[string]any{"file_id": fileID})
	if err != nil {
		return "", nil, err
	}
	var payload struct {
		Link      string `json:"link"`
		FileName  string `json:"file_name"`
		Remaining int    `json:"remaining"`
		ResetTime string `json:"reset_time"`
		Message   string `json:"message"`
	}
	if err := c.post(ctx, "/download", body, &payload); err != nil {
		var apiErr *APIError
		// 406 is what the API answers with when the allowance is gone. It is the one
		// failure a viewer can act on — by waiting — so it keeps its own type.
		if ok := asAPIError(err, &apiErr); ok && apiErr.StatusCode == http.StatusNotAcceptable {
			return "", nil, &QuotaError{}
		}
		return "", nil, err
	}
	if strings.TrimSpace(payload.Link) == "" {
		return "", nil, &QuotaError{ResetTime: payload.ResetTime}
	}

	// The link is a plain file on a CDN and carries neither the API key nor the token.
	// Sending them would leak the household's credentials to a host that is not the API.
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, payload.Link, nil)
	if err != nil {
		return "", nil, err
	}
	req.Header.Set("User-Agent", c.userAgent)
	resp, err := c.http.Do(req)
	if err != nil {
		return "", nil, fmt.Errorf("opensubtitles: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return "", nil, &APIError{StatusCode: resp.StatusCode, Body: "download link"}
	}
	content, err = io.ReadAll(io.LimitReader(resp.Body, maxSubtitleBytes))
	if err != nil {
		return "", nil, fmt.Errorf("opensubtitles: read subtitle: %w", err)
	}
	if len(content) == 0 {
		return "", nil, fmt.Errorf("opensubtitles: empty subtitle file")
	}
	return payload.FileName, content, nil
}

// Ping is the reachability and credential check the console uses. It reads the account
// endpoint when there is an account and the plain info endpoint otherwise, so "the key
// works" and "the login works" are two different answers.
func (c *Client) Ping(ctx context.Context) error {
	if c.HasAccount() {
		if _, err := c.authToken(ctx); err != nil {
			return err
		}
	}
	var payload struct {
		Data map[string]any `json:"data"`
	}
	return c.get(ctx, "/infos/formats", nil, &payload)
}

// authToken returns a login token, logging in if the cached one is missing or old. An
// account that will not log in is not fatal: the download is attempted anonymously, which
// works until the small anonymous allowance runs out and is a better answer than refusing
// to try.
func (c *Client) authToken(ctx context.Context) (string, error) {
	if !c.HasAccount() {
		return "", nil
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.token != "" && time.Now().Before(c.tokenExpiry) {
		return c.token, nil
	}
	body, err := json.Marshal(map[string]string{"username": c.username, "password": c.password})
	if err != nil {
		return "", err
	}
	var payload struct {
		Token string `json:"token"`
	}
	// Deliberately not through post(): that asks for a token, and this is how one is got.
	if err := c.do(ctx, http.MethodPost, "/login", nil, body, "", &payload); err != nil {
		return "", err
	}
	if strings.TrimSpace(payload.Token) == "" {
		return "", fmt.Errorf("opensubtitles: login returned no token")
	}
	c.token = payload.Token
	c.tokenExpiry = time.Now().Add(tokenLifetime)
	return c.token, nil
}

func (c *Client) get(ctx context.Context, path string, params url.Values, out any) error {
	token, _ := c.authToken(ctx)
	return c.do(ctx, http.MethodGet, path, params, nil, token, out)
}

func (c *Client) post(ctx context.Context, path string, body []byte, out any) error {
	token, _ := c.authToken(ctx)
	return c.do(ctx, http.MethodPost, path, nil, body, token, out)
}

func (c *Client) do(
	ctx context.Context, method, path string, params url.Values,
	body []byte, token string, out any,
) error {
	endpoint := c.baseURL + path
	if len(params) > 0 {
		endpoint += "?" + params.Encode()
	}
	var reader io.Reader
	if body != nil {
		reader = bytes.NewReader(body)
	}
	req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
	if err != nil {
		return err
	}
	req.Header.Set("Api-Key", c.apiKey)
	req.Header.Set("Accept", "application/json")
	// The API rejects a request with no User-Agent naming the consumer, and it is the one
	// header here that is about being a good citizen rather than about authentication.
	req.Header.Set("User-Agent", c.userAgent)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}

	resp, err := c.http.Do(req)
	if err != nil {
		return fmt.Errorf("opensubtitles: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
		return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(raw))}
	}
	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("opensubtitles: decode response: %w", err)
	}
	return nil
}

func asAPIError(err error, target **APIError) bool {
	if converted, ok := err.(*APIError); ok {
		*target = converted
		return true
	}
	return false
}
