// Package allanime provides an integration with the AllAnime API for episode playback.
package allanime

import (
	"bytes"
	"context"
	"crypto/aes"
	"crypto/cipher"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"mal/internal/domain"
	"mal/pkg"
	netutil "mal/pkg/net"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"
)

const (
	allAnimeBaseURL  = "https://api.allanime.day"
	allAnimeReferer  = "https://allmanga.to/"
	allAnimeOrigin   = "https://youtu-chan.com"
	defaultUserAgent = netutil.Firefox121
)

var (
	aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
)

type searchResult struct {
	ID    string
	MalID string
	Name  string
}

type AvailableEpisodes struct {
	Sub []string
	Dub []string
	Raw []string
}

type AllAnimeProvider struct {
	httpClient *http.Client
	utlsClient *http.Client
	extractor  *providerExtractor
}

func NewAllAnimeProvider() *AllAnimeProvider {
	return &AllAnimeProvider{
		httpClient: &http.Client{
			Timeout: 30 * time.Second,
		},
		utlsClient: &http.Client{
			Transport: &netutil.UtlsRoundTripper{},
			Timeout:   30 * time.Second,
		},
		extractor: newProviderExtractor(),
	}
}

func (c *AllAnimeProvider) Name() string {
	return "AllAnime"
}

const searchQuery = `query(
  $search: SearchInput
  $translationType: VaildTranslationTypeEnumType
  $limit: Int = 40
  $page: Int = 1
  $countryOrigin: VaildCountryOriginEnumType = ALL
) {
  shows(
    search: $search
    limit: $limit
    page: $page
    translationType: $translationType
    countryOrigin: $countryOrigin
  ) {
    edges {
      _id
      malId
      name
    }
  }
}`

func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
	type searchData struct {
		Shows struct {
			Edges []struct {
				ID    string `json:"_id"`
				MalID string `json:"malId"`
				Name  string `json:"name"`
			} `json:"edges"`
		} `json:"shows"`
	}

	type searchInput struct {
		AllowAdult   bool   `json:"allowAdult"`
		AllowUnknown bool   `json:"allowUnknown"`
		Query        string `json:"query"`
	}

	type searchVariables struct {
		Search          searchInput `json:"search"`
		TranslationType string      `json:"translationType"`
	}

	vars := searchVariables{
		Search: searchInput{
			AllowAdult:   false,
			AllowUnknown: false,
			Query:        query,
		},
		TranslationType: mode,
	}

	data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{
		Headers: map[string]string{
			"Referer":    allAnimeReferer,
			"User-Agent": defaultUserAgent,
		},
		BodyMax: netutil.MiB2,
	})
	if err != nil {
		return nil, err
	}

	out := make([]searchResult, 0, len(data.Shows.Edges))
	for _, edge := range data.Shows.Edges {
		id := edge.ID
		malID := edge.MalID
		name := edge.Name
		if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
			name = unquoted
		}
		name = strings.TrimSpace(name)

		if id == "" {
			continue
		}

		out = append(out, searchResult{ID: id, MalID: malID, Name: name})
	}

	return out, nil
}

func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
	// 1. Search for the show to get its AllAnime ID
	// Try each title candidate, preferring results with matching malId
	targetMalIDStr := strconv.Itoa(animeID)
	var showID string
	var firstAvailableShowID string

	for _, title := range titleCandidates {
		searchResults, err := c.Search(ctx, title, mode)
		if err != nil || len(searchResults) == 0 {
			continue
		}

		for _, res := range searchResults {
			if res.MalID == targetMalIDStr {
				showID = res.ID
				break
			}
		}

		if showID != "" {
			break
		}

		if firstAvailableShowID == "" {
			firstAvailableShowID = searchResults[0].ID
		}
	}

	if showID == "" {
		showID = firstAvailableShowID
	}

	if showID == "" {
		return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
	}

	// 2. Get sources
	sources, err := c.GetEpisodeSources(ctx, showID, episode, mode)
	if err != nil || len(sources) == 0 {
		return nil, fmt.Errorf("allanime: no sources for show %s", showID)
	}

	// 3. Return the first usable source
	primary := sources[0]

	result := &domain.StreamResult{
		URL:     primary.URL,
		Referer: primary.Referer,
	}

	for _, sub := range primary.Subtitles {
		result.Subtitles = append(result.Subtitles, domain.Subtitle{
			Label: sub.Lang,
			URL:   sub.URL,
		})
	}

	return result, nil
}

func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) {
	showID, err := c.ResolveEpisodeProviderID(ctx, animeID, titleCandidates)
	if err != nil {
		return domain.EpisodeAvailability{}, err
	}
	return c.GetEpisodeAvailabilityByProviderID(ctx, showID)
}

func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
	for _, mode := range []string{"sub", "dub"} {
		showID, err := c.resolveShowIDStrict(ctx, animeID, titleCandidates, mode)
		if err == nil {
			return showID, nil
		}
	}
	return "", fmt.Errorf("allanime: no exact mal id match for %d", animeID)
}

func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Context, showID string) (domain.EpisodeAvailability, error) {
	available, err := c.GetAvailableEpisodes(ctx, showID)
	if err != nil {
		return domain.EpisodeAvailability{}, err
	}

	sub := parseEpisodeNumbers(append(available.Sub, available.Raw...))
	dub := parseEpisodeNumbers(available.Dub)
	return domain.EpisodeAvailability{Sub: sub, Dub: dub}, nil
}

func (c *AllAnimeProvider) resolveShowIDStrict(ctx context.Context, animeID int, titleCandidates []string, mode string) (string, error) {
	targetMalIDStr := strconv.Itoa(animeID)
	for _, title := range titleCandidates {
		searchResults, err := c.Search(ctx, title, mode)
		if err != nil {
			continue
		}
		for _, res := range searchResults {
			if res.MalID == targetMalIDStr {
				return res.ID, nil
			}
		}
	}
	return "", fmt.Errorf("allanime: no exact mal id match for %d in %s search", animeID, mode)
}

func parseEpisodeNumbers(raw []string) []int {
	seen := make(map[int]bool, len(raw))
	out := make([]int, 0, len(raw))
	for _, value := range raw {
		n, err := strconv.Atoi(strings.TrimSpace(value))
		if err != nil || n <= 0 || seen[n] {
			continue
		}
		seen[n] = true
		out = append(out, n)
	}
	return out
}

func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, variables map[string]any) (map[string]any, error) {
	if mode, ok := variables["translationType"].(string); ok {
		variables["translationType"] = strings.ToLower(mode)
	}

	payload := map[string]any{
		"query":     query,
		"variables": variables,
	}

	body, err := json.Marshal(payload)
	if err != nil {
		return nil, fmt.Errorf("marshal graphql payload: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, allAnimeBaseURL+"/api", bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("create graphql request: %w", err)
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Referer", allAnimeReferer)
	req.Header.Set("User-Agent", defaultUserAgent)

	resp, respBody, err := executeAndReadResponse(c.httpClient, req, "execute graphql request", "read graphql response")
	if err != nil {
		return nil, err
	}

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("graphql status %d", resp.StatusCode)
	}

	var parsed map[string]any
	if err := json.Unmarshal(respBody, &parsed); err != nil {
		return nil, fmt.Errorf("decode graphql response: %w", err)
	}

	if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 {
		return nil, fmt.Errorf("graphql error: %v", errs[0])
	}

	return parsed, nil
}

const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"

func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
	mode = strings.ToLower(mode)

	varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, mode, episode)
	extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash)

	apiURL := fmt.Sprintf("%s/api?variables=%s&extensions=%s",
		allAnimeBaseURL,
		url.QueryEscape(varsJSON),
		url.QueryEscape(extJSON))

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
	if err != nil {
		return nil, fmt.Errorf("create GET request: %w", err)
	}

	req.Header.Set("User-Agent", defaultUserAgent)
	req.Header.Set("Accept", "*/*")
	req.Header.Set("Accept-Language", "en-US,en;q=0.5")
	req.Header.Set("Accept-Encoding", "identity")
	req.Header.Set("Referer", allAnimeReferer)
	req.Header.Set("Origin", allAnimeOrigin)
	req.Header.Set("Sec-Fetch-Dest", "empty")
	req.Header.Set("Sec-Fetch-Mode", "cors")
	req.Header.Set("Sec-Fetch-Site", "cross-site")

	resp, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response")
	if err != nil {
		return nil, err
	}

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("GET status %d: %s", resp.StatusCode, string(respBody))
	}

	var parsed map[string]any
	if err := json.Unmarshal(respBody, &parsed); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}

	if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 {
		return nil, fmt.Errorf("graphql error: %v", errs[0])
	}

	data, ok := parsed["data"].(map[string]any)
	if !ok {
		return nil, fmt.Errorf("no data in response")
	}

	var toBeParsed string
	if s, ok := data["tobeparsed"].(string); ok && s != "" {
		toBeParsed = s
	} else if episodeData, ok := data["episode"].(map[string]any); ok {
		if s, ok := episodeData["tobeparsed"].(string); ok {
			toBeParsed = s
		}
	}

	if toBeParsed != "" {
		decrypted, err := decryptTobeparsed(toBeParsed)
		if err != nil {
			return nil, fmt.Errorf("decrypt tobeparsed: %w", err)
		}

		var ep map[string]any
		if jerr := json.Unmarshal(decrypted, &ep); jerr != nil {
			return nil, fmt.Errorf("unmarshal decrypted: %w", jerr)
		}

		var sourceURLs []any
		if srcs, ok := ep["sourceUrls"].([]any); ok {
			sourceURLs = srcs
		} else if epInner, ok := ep["episode"].(map[string]any); ok {
			if srcs, ok := epInner["sourceUrls"].([]any); ok {
				sourceURLs = srcs
			}
		}

		if len(sourceURLs) > 0 {
			return map[string]any{
				"episode": map[string]any{
					"sourceUrls": sourceURLs,
				},
			}, nil
		}
	}

	if episodeData, ok := data["episode"].(map[string]any); ok {
		if srcs, ok := episodeData["sourceUrls"].([]any); ok && len(srcs) > 0 {
			return parsed, nil
		}
	}

	return nil, fmt.Errorf("no usable data in response")
}

// GetEpisodeSources fetches stream URLs for a given show, episode, and mode (dub/sub).
func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
	episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
		episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
			sourceUrls
		}
	}`

	result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode)
	if err == nil {
		sources := c.extractSourceURLsFromData(ctx, result)
		if len(sources) > 0 {
			return sources, nil
		}
	}

	result, err = c.graphqlRequest(ctx, episodeQuery, map[string]any{
		"showId":          showID,
		"translationType": mode,
		"episodeString":   episode,
	})
	if err != nil {
		return nil, err
	}

	data, ok := result["data"].(map[string]any)
	if !ok {
		return nil, fmt.Errorf("invalid source response")
	}

	rawSourceURLs, ok := data["episode"].(map[string]any)
	if !ok {
		return nil, fmt.Errorf("invalid episode response")
	}

	sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any)
	if !ok || len(sourceURLs) == 0 {
		return nil, fmt.Errorf("no source urls")
	}

	references := buildSourceReferences(sourceURLs)
	if len(references) == 0 {
		return nil, fmt.Errorf("no source references")
	}

	out := c.resolveSourceReferences(ctx, references)

	if len(out) == 0 {
		return nil, fmt.Errorf("no playable sources extracted")
	}

	return out, nil
}

func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data map[string]any) []StreamSource {
	episodeData, ok := data["episode"].(map[string]any)
	if !ok {
		return nil
	}

	sourceURLs, ok := episodeData["sourceUrls"].([]any)
	if !ok || len(sourceURLs) == 0 {
		return nil
	}

	references := buildSourceReferences(sourceURLs)
	if len(references) == 0 {
		return nil
	}

	return c.resolveSourceReferences(ctx, references)
}

func (c *AllAnimeProvider) resolveSourceReferences(ctx context.Context, references []sourceReference) []StreamSource {
	out := make([]StreamSource, 0, len(references))
	for _, ref := range references {
		target := strings.TrimSpace(ref.URL)
		if target == "" {
			continue
		}

		if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
			sourceType := detectStreamType(target)
			if sourceType == "unknown" {
				sourceType = detectEmbedType(target)
			}

			out = append(out, buildStreamSource(target, sourceType, ref.Name))
			continue
		}

		decoded := decodeSourceURL(target)
		if decoded == "" {
			continue
		}

		if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") {
			sourceType := detectStreamType(decoded)
			if sourceType == "unknown" {
				sourceType = detectEmbedType(decoded)
			}

			out = append(out, buildStreamSource(decoded, sourceType, ref.Name))
			continue
		}

		if !strings.HasPrefix(decoded, "/") {
			decoded = "/" + decoded
		}

		extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
		if err != nil {
			continue
		}

		out = append(out, extracted...)
	}

	return out
}

func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPrefix string, readErrPrefix string) (*http.Response, []byte, error) {
	resp, err := client.Do(req)
	if err != nil {
		return nil, nil, fmt.Errorf("%s: %w", executeErrPrefix, err)
	}
	defer func() { _ = resp.Body.Close() }()

	body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
	if err != nil {
		return nil, nil, fmt.Errorf("%s: %w", readErrPrefix, err)
	}

	return resp, body, nil
}

func buildStreamSource(url, sourceType, provider string) StreamSource {
	return StreamSource{
		URL:      url,
		Provider: provider,
		Type:     sourceType,
		Referer:  allAnimeReferer,
	}
}

type sourceReference struct {
	URL  string
	Name string
}

// buildSourceReferences orders source URLs by provider priority, deduplicating entries.
func buildSourceReferences(rawSourceURLs []any) []sourceReference {
	priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
	prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}

	prioritized := make(map[string]sourceReference)
	fallback := make([]sourceReference, 0, len(rawSourceURLs))
	seen := make(map[string]struct{})

	for _, source := range rawSourceURLs {
		item, ok := source.(map[string]any)
		if !ok {
			continue
		}

		sourceURL, _ := item["sourceUrl"].(string)
		sourceName, _ := item["sourceName"].(string)
		sourceURL = strings.TrimSpace(sourceURL)
		sourceName = strings.TrimSpace(sourceName)
		if sourceURL == "" {
			continue
		}

		if _, exists := seen[sourceURL]; exists {
			continue
		}
		seen[sourceURL] = struct{}{}

		ref := sourceReference{URL: sourceURL, Name: sourceName}
		normalized := strings.ToLower(sourceName)
		// separate prioritized providers from fallback
		if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider {
			if _, exists := prioritized[normalized]; !exists {
				prioritized[normalized] = ref
			}
			continue
		}

		fallback = append(fallback, ref)
	}

	// output: prioritized in order, then fallback
	ordered := make([]sourceReference, 0, len(prioritized)+len(fallback))
	for _, provider := range priorityOrder {
		if ref, ok := prioritized[provider]; ok {
			ordered = append(ordered, ref)
		}
	}

	ordered = append(ordered, fallback...)
	return ordered
}

func decryptTobeparsed(encoded string) ([]byte, error) {
	raw, err := base64.StdEncoding.DecodeString(encoded)
	if err != nil {
		return nil, fmt.Errorf("base64 decode failed: %w", err)
	}

	if len(raw) < 29 {
		return nil, fmt.Errorf("encrypted payload too short")
	}

	version := raw[0]
	iv := raw[1:13]
	cipherText := raw[13 : len(raw)-16]

	for _, keyStr := range aesKeys {
		key := sha256.Sum256([]byte(keyStr))

		block, err := aes.NewCipher(key[:])
		if err != nil {
			continue
		}

		if version == 1 {
			plainText := tryDecryptCTR(block, iv, cipherText)
			if json.Valid(plainText) {
				return plainText, nil
			}
		}

		gcm, err := cipher.NewGCM(block)
		if err == nil {
			tag := raw[len(raw)-16:]
			combined := append(append([]byte{}, cipherText...), tag...)
			plainText, openErr := gcm.Open(nil, iv, combined, nil)
			if openErr == nil && json.Valid(plainText) {
				return plainText, nil
			}
		}
	}

	return nil, fmt.Errorf("decryption failed")
}

func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) []byte {
	ctrIV := append([]byte{}, iv...)
	ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
	ctr := cipher.NewCTR(block, ctrIV)
	plainText := make([]byte, len(cipherText))
	ctr.XORKeyStream(plainText, cipherText)
	return plainText
}

// GetAvailableEpisodes returns the count of sub/dub/raw episodes available for a show.
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
	graphqlQuery := `query($showId: String!) {
		show(_id: $showId) {
			availableEpisodesDetail
			lastEpisodeInfo
		}
	}`

	result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{"showId": showID})
	if err != nil {
		return AvailableEpisodes{}, err
	}

	data, ok := result["data"].(map[string]any)
	if !ok {
		return AvailableEpisodes{}, fmt.Errorf("invalid response")
	}

	show, ok := data["show"].(map[string]any)
	if !ok || show == nil {
		return AvailableEpisodes{}, fmt.Errorf("show not found")
	}

	detail, ok := show["availableEpisodesDetail"].(map[string]any)
	if !ok {
		return AvailableEpisodes{}, fmt.Errorf("invalid detail")
	}

	var count AvailableEpisodes
	if sub, ok := detail["sub"].([]any); ok {
		for _, s := range sub {
			if str, ok := s.(string); ok {
				count.Sub = append(count.Sub, str)
			}
		}
	}
	if dub, ok := detail["dub"].([]any); ok {
		for _, s := range dub {
			if str, ok := s.(string); ok {
				count.Dub = append(count.Dub, str)
			}
		}
	}
	if raw, ok := detail["raw"].([]any); ok {
		for _, s := range raw {
			if str, ok := s.(string); ok {
				count.Raw = append(count.Raw, str)
			}
		}
	}

	return count, nil
}

func decodeSourceURL(encoded string) string {
	if encoded == "" {
		return ""
	}

	encoded = strings.TrimPrefix(encoded, "--")

	substitutions := map[string]string{
		"79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E",
		"7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J",
		"73": "K", "74": "L", "75": "M", "76": "N", "77": "O",
		"68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T",
		"6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y",
		"62": "Z",
		"59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e",
		"5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j",
		"53": "k", "54": "l", "55": "m", "56": "n", "57": "o",
		"48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t",
		"4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y",
		"42": "z",
		"08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4",
		"0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9",
		"15": "-", "16": ".", "67": "_", "46": "~", "02": ":",
		"17": "/", "07": "?", "1b": "#", "63": "[", "65": "]",
		"78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(",
		"11": ")", "12": "*", "13": "+", "14": ",", "03": ";",
		"05": "=", "1d": "%",
	}

	var result strings.Builder
	for idx := 0; idx < len(encoded); {
		if idx+2 <= len(encoded) {
			pair := encoded[idx : idx+2]
			if sub, ok := substitutions[pair]; ok {
				result.WriteString(sub)
				idx += 2
				continue
			}
		}

		result.WriteByte(encoded[idx])
		idx++
	}

	decoded := result.String()
	if strings.Contains(decoded, "/clock") && !strings.Contains(decoded, "/clock.json") {
		decoded = strings.Replace(decoded, "/clock", "/clock.json", 1)
	}

	return decoded
}

func detectStreamType(sourceURL string) string {
	lower := strings.ToLower(sourceURL)
	if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") {
		return "m3u8"
	}

	if strings.Contains(lower, ".mp4") {
		return "mp4"
	}

	return "unknown"
}

func detectEmbedType(rawURL string) string {
	lower := strings.ToLower(rawURL)
	embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"}
	for _, host := range embedHosts {
		if strings.Contains(lower, host) {
			return "embed"
		}
	}

	return "unknown"
}
