package src

import (
	"cmp"
	"context"
	"crypto/sha1"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"log/slog"
	"mime"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/zoriya/kyoo/transcoder/src/utils"
	"golang.org/x/text/language"
	"gopkg.in/vansante/go-ffprobe.v2"
)

const InfoVersion = 5

type Versions struct {
	Info        int32 `json:"info" db:"ver_info"`
	Extract     int32 `json:"extract" db:"ver_extract"`
	Thumbs      int32 `json:"thumbs" db:"ver_thumbs"`
	Keyframes   int32 `json:"keyframes" db:"ver_keyframes"`
	Fingerprint int32 `json:"fingerprint" db:"ver_fingerprint"`
	/// List of sha this was fingerprinted with
	FpWith []string `json:"fpWith" db:"ver_fp_with"`
}

type MediaInfo struct {
	// Auto-increment id used as foreign key for related tables.
	Id int32 `json:"id" db:"id"`
	// The sha1 of the video file.
	Sha string `json:"sha" db:"sha"`
	/// The internal path of the video file.
	Path string `json:"path" db:"path"`
	/// The extension currently used to store this video file
	Extension string `json:"extension" db:"extension"`
	/// The whole mimetype (defined as the RFC 6381). ex: `video/mp4; codecs=\"avc1.640028, mp4a.40.2\"`
	MimeCodec *string `json:"mimeCodec" db:"mime_codec"`
	/// The file size of the video file.
	Size int64 `json:"size" db:"size"`
	/// The length of the media in seconds.
	Duration float64 `json:"duration" db:"duration"`
	/// The container of the video file of this episode.
	Container *string `json:"container" db:"container"`
	/// Version of the metadata. This can be used to invalidate older metadata from db if the extraction code has changed.
	Versions Versions `json:"versions" db:"versions"`

	/// The list of videos if there are multiples.
	Videos []Video `json:"videos" db:"-"`
	/// The list of audio tracks.
	Audios []Audio `json:"audios" db:"-"`
	/// The list of subtitles tracks.
	Subtitles []Subtitle `json:"subtitles" db:"-"`
	/// The list of fonts that can be used to display subtitles.
	Fonts []string `json:"fonts" db:"fonts"`
	/// The list of chapters. See Chapter for more information.
	Chapters []Chapter `json:"chapters" db:"-"`

	/// lock used to read/set keyframes of video/audio
	lock sync.Mutex `json:"-" db:"-"`
}

type Video struct {
	Id int32 `json:"-" db:"id"`

	/// The index of this track on the media.
	Index uint32 `json:"index" db:"idx"`
	/// The title of the stream.
	Title *string `json:"title" db:"title"`
	/// The language of this stream (as a ISO-639-2 language code)
	Language *string `json:"language" db:"language"`
	/// The human readable codec name.
	Codec string `json:"codec" db:"codec"`
	/// The codec of this stream (defined as the RFC 6381).
	MimeCodec *string `json:"mimeCodec" db:"mime_codec"`
	/// The width of the video stream
	Width uint32 `json:"width" db:"width"`
	/// The height of the video stream
	Height uint32 `json:"height" db:"height"`
	/// The average bitrate of the video in bytes/s
	Bitrate uint32 `json:"bitrate" db:"bitrate"`
	/// Is this stream the default one of it's type?
	IsDefault bool `json:"isDefault" db:"is_default"`

	/// Keyframes of this video
	Keyframes *Keyframe `json:"-"`
}

type Audio struct {
	Id int32 `json:"-" db:"id"`

	/// The index of this track on the media.
	Index uint32 `json:"index" db:"idx"`
	/// The title of the stream.
	Title *string `json:"title" db:"title"`
	/// The language of this stream (as a IETF-BCP-47 language code)
	Language *string `json:"language" db:"language"`
	/// The human readable codec name.
	Codec string `json:"codec" db:"codec"`
	/// The codec of this stream (defined as the RFC 6381).
	MimeCodec *string `json:"mimeCodec" db:"mime_codec"`
	/// The number of channels that stream has.
	Channels int `json:"channels" db:"channels"`
	/// The average bitrate of the audio in bytes/s
	Bitrate uint32 `json:"bitrate" db:"bitrate"`
	/// Is this stream the default one of it's type?
	IsDefault bool `json:"isDefault" db:"is_default"`

	/// Keyframes of this video
	Keyframes *Keyframe `json:"-"`
}

type Subtitle struct {
	Id int32 `json:"-" db:"id"`

	/// The index of this track on the media.
	Index *uint32 `json:"index" db:"idx"`
	/// The title of the stream.
	Title *string `json:"title" db:"title"`
	/// The language of this stream (as a IETF-BCP-47 language code)
	Language *string `json:"language" db:"language"`
	/// The codec of this stream.
	Codec string `json:"codec" db:"codec"`
	/// The extension for the codec.
	Extension *string `json:"extension" db:"extension"`
	/// Is this stream the default one of it's type?
	IsDefault bool `json:"isDefault" db:"is_default"`
	/// Is this stream tagged as forced?
	IsForced bool `json:"isForced" db:"is_forced"`
	/// Is this stream tagged as hearing impaired?
	IsHearingImpaired bool `json:"isHearingImpaired" db:"is_hearing_impaired"`
	/// Is this an external subtitle (as in stored in a different file)
	IsExternal bool `json:"isExternal" db:"-"`
	/// Where the subtitle is stored (null if stored inside the video)
	Path *string `json:"path" db:"-"`
	/// The link to access this subtitle.
	Link *string `json:"link" db:"-"`
}

type Chapter struct {
	Id int32 `json:"-" db:"id"`

	/// The start time of the chapter (in second from the start of the episode).
	StartTime float32 `json:"startTime" db:"start_time"`
	/// The end time of the chapter (in second from the start of the episode).
	EndTime float32 `json:"endTime" db:"end_time"`
	/// The name of this chapter. This should be a human-readable name that could be presented to the user.
	Name string `json:"name" db:"name"`
	/// The type value is used to mark special chapters (opening/credits...)
	Type ChapterType `json:"type" db:"type"`
	// true only for introductions where the audio track is new (first time we'we heard this one in the serie)
	FirstAppearance *bool `json:"firstAppearance,omitempty" db:"first_appearance"`
	/// Accuracy of the fingerprint match (0-100).
	MatchAccuracy *int32 `json:"matchAccuracy,omitempty" db:"match_accuracy"`
}

type ChapterType string

const (
	Content ChapterType = "content"
	Recap   ChapterType = "recap"
	Intro   ChapterType = "intro"
	Credits ChapterType = "credits"
	Preview ChapterType = "preview"
)

// regex stolen from https://github.com/intro-skipper/intro-skipper/wiki/Chapter-Detection-Patterns
var chapterTypePatterns = []struct {
	kind    ChapterType
	pattern *regexp.Regexp
}{
	{kind: Recap, pattern: regexp.MustCompile(`(?i)\b(re?cap|sum+ary|prev(ious(ly)?)?|(last|earlier)(\b\w+)?|catch\bup)\b`)},
	{kind: Intro, pattern: regexp.MustCompile(`(?i)\b(intro|introduction|op|opening)\b`)},
	{kind: Credits, pattern: regexp.MustCompile(`(?i)\b(credits?|ed|ending|outro)\b`)},
	{kind: Preview, pattern: regexp.MustCompile(`(?i)\b(preview|pv|sneak\b?peek|coming\b?(up|soon)|next\b+(time|on|episode)|extra|teaser|trailer)\b`)},
}

func identifyChapterType(name string) ChapterType {
	for _, matcher := range chapterTypePatterns {
		if matcher.pattern.MatchString(name) {
			return matcher.kind
		}
	}
	return Content
}

func ParseFloat(str string) float32 {
	f, err := strconv.ParseFloat(str, 32)
	if err != nil {
		return 0
	}
	return float32(f)
}

func ParseUint(str string) uint32 {
	i, err := strconv.ParseUint(str, 10, 32)
	if err != nil {
		slog.WarnContext(context.WithoutCancel(context.Background()), "failed to parse uint", "value", str, "err", err)
		return 0
	}
	return uint32(i)
}

func ParseInt64(str string) int64 {
	i, err := strconv.ParseInt(str, 10, 64)
	if err != nil {
		slog.WarnContext(context.WithoutCancel(context.Background()), "failed to parse int64", "value", str, "err", err)
		return 0
	}
	return i
}

func Map[T, U any](ts []T, f func(T, int) U) []U {
	us := make([]U, len(ts))
	for i := range ts {
		us[i] = f(ts[i], i)
	}
	return us
}

func MapStream[T any](streams []*ffprobe.Stream, kind ffprobe.StreamType, mapper func(*ffprobe.Stream, uint32) T) []T {
	count := 0
	for _, stream := range streams {
		if stream.CodecType == string(kind) {
			count++
		}
	}
	ret := make([]T, count)

	i := uint32(0)
	for _, stream := range streams {
		if stream.CodecType == string(kind) {
			ret[i] = mapper(stream, i)
			i++
		}
	}
	return ret
}

func OrNull(str string) *string {
	if str == "" {
		return nil
	}
	return &str
}

func NullIfUnd(str string) *string {
	if str == "und" {
		return nil
	}
	return &str
}

var SubtitleExtensions = map[string]string{
	"subrip":            "srt",
	"ass":               "ass",
	"vtt":               "vtt",
	"hdmv_pgs_subtitle": "sup",
}

func RetriveMediaInfo(ctx context.Context, path string, sha string) (*MediaInfo, error) {
	ctx, cancelFn := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
	defer cancelFn()
	defer utils.PrintExecTime(ctx, "mediainfo for %s", path)()

	mi, err := ffprobe.ProbeURL(ctx, path)
	if err != nil {
		return nil, err
	}

	ret := MediaInfo{
		Sha:       sha,
		Path:      path,
		Extension: strings.TrimPrefix(filepath.Ext(path), "."),
		Size:      ParseInt64(mi.Format.Size),
		Duration:  mi.Format.DurationSeconds,
		Container: OrNull(mi.Format.FormatName),
		Versions: Versions{
			Info:        InfoVersion,
			Extract:     0,
			Thumbs:      0,
			Keyframes:   0,
			Fingerprint: 0,
		},
		Videos: MapStream(mi.Streams, ffprobe.StreamVideo, func(stream *ffprobe.Stream, i uint32) Video {
			lang, _ := language.Parse(stream.Tags.Language)
			return Video{
				Index:     i,
				Codec:     stream.CodecName,
				MimeCodec: GetMimeCodec(stream),
				Title:     OrNull(stream.Tags.Title),
				Language:  NullIfUnd(lang.String()),
				Width:     uint32(stream.Width),
				Height:    uint32(stream.Height),
				// ffmpeg does not report bitrate in mkv files, fallback to bitrate of the whole container
				// (bigger than the result since it contains audio and other videos but better than nothing).
				Bitrate:   ParseUint(cmp.Or(stream.BitRate, mi.Format.BitRate)),
				IsDefault: stream.Disposition.Default != 0,
			}
		}),
		Audios: MapStream(mi.Streams, ffprobe.StreamAudio, func(stream *ffprobe.Stream, i uint32) Audio {
			lang, _ := language.Parse(stream.Tags.Language)
			return Audio{
				Index:     i,
				Title:     OrNull(stream.Tags.Title),
				Language:  NullIfUnd(lang.String()),
				Codec:     stream.CodecName,
				MimeCodec: GetMimeCodec(stream),
				Channels:  stream.Channels,
				Bitrate:   ParseUint(cmp.Or(stream.BitRate, "0")),
				IsDefault: stream.Disposition.Default != 0,
			}
		}),
		Subtitles: MapStream(mi.Streams, ffprobe.StreamSubtitle, func(stream *ffprobe.Stream, i uint32) Subtitle {
			extension := OrNull(SubtitleExtensions[stream.CodecName])
			var link string
			if extension != nil {
				link = fmt.Sprintf("/video/%s/subtitle/%d.%s", base64.RawURLEncoding.EncodeToString([]byte(path)), i, *extension)
			}
			lang, _ := language.Parse(stream.Tags.Language)
			idx := uint32(i)
			return Subtitle{
				Index:             &idx,
				Title:             OrNull(stream.Tags.Title),
				Language:          NullIfUnd(lang.String()),
				Codec:             stream.CodecName,
				Extension:         extension,
				IsDefault:         stream.Disposition.Default != 0,
				IsForced:          stream.Disposition.Forced != 0,
				IsHearingImpaired: stream.Disposition.HearingImpaired != 0,
				Link:              &link,
			}
		}),
		Chapters: Map(mi.Chapters, func(c *ffprobe.Chapter, _ int) Chapter {
			return Chapter{
				Name:      c.Title(),
				StartTime: float32(c.StartTimeSeconds),
				EndTime:   float32(c.EndTimeSeconds),
				Type:      identifyChapterType(c.Title()),
			}
		}),
		Fonts: MapStream(mi.Streams, ffprobe.StreamAttachment, func(stream *ffprobe.Stream, i uint32) string {
			font, _ := stream.TagList.GetString("filename")
			return fmt.Sprintf("/video/%s/attachment/%s", base64.RawURLEncoding.EncodeToString([]byte(path)), font)
		}),
	}
	var codecs []string
	if len(ret.Videos) > 0 && ret.Videos[0].MimeCodec != nil {
		codecs = append(codecs, *ret.Videos[0].MimeCodec)
	}
	if len(ret.Audios) > 0 && ret.Audios[0].MimeCodec != nil {
		codecs = append(codecs, *ret.Audios[0].MimeCodec)
	}
	container := mime.TypeByExtension(fmt.Sprintf(".%s", ret.Extension))
	if container != "" {
		if len(codecs) > 0 {
			codecs_str := strings.Join(codecs, ", ")
			mime := fmt.Sprintf("%s; codecs=\"%s\"", container, codecs_str)
			ret.MimeCodec = &mime
		} else {
			ret.MimeCodec = &container
		}
	}
	return &ret, nil
}

// ComputeSha computes a SHA1 hash of the file path and its modification time.
// This is used as a cache key to detect when a file has changed.
func ComputeSha(path string) (string, error) {
	info, err := os.Stat(path)
	if err != nil {
		return "", err
	}
	h := sha1.New()
	h.Write([]byte(path))
	h.Write([]byte(info.ModTime().String()))
	return hex.EncodeToString(h.Sum(nil)), nil
}
