package recommend

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"math"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/ponzischeme89/memby/server/internal/emby"
	"github.com/ponzischeme89/memby/server/internal/tracearr"
)

type PreparedLibrarySource interface {
	AllRecommendationCandidates(ctx context.Context) ([]json.RawMessage, error)
}

type PreparedEvidence struct {
	SessionID string   `json:"sessionId"`
	ItemID    string   `json:"itemId"`
	Title     string   `json:"title"`
	Genres    []string `json:"genres,omitempty"`
}

type PreparedTitleAffinity struct {
	Weight    float64 `json:"weight"`
	Title     string  `json:"title"`
	SessionID string  `json:"sessionId,omitempty"`
}

type PreparedSessionMapping struct {
	ServerID  string
	SessionID string
	ItemID    string
	SeriesID  string
}

type PreparedProfile struct {
	TracearrUserID        string
	TracearrUsername      string
	SourceSessionCount    int
	MeanCompletionRatio   float64
	TypicalSessionMinutes int
	GenreAffinity         map[string]float64
	TitleAffinity         map[string]PreparedTitleAffinity
	StudioAffinity        map[string]float64
	ContextAffinity       ContextAffinityProfile
	CodecOutcomes         map[string]map[string]int
	Weighted              WeightedProfile
	SignalsThrough        *time.Time
}

type PreparedCandidate struct {
	ItemID                string
	BaseRank              int
	BaseScore             float64
	RuntimeMinutes        int
	AffinityScore         float64
	CompatibilityScore    float64
	CompatibilityLabel    string
	ReasonKind            string
	ReasonGenre           string
	ReasonSourceSessionID string
	ReasonSourceItemID    string
	ReasonSourceTitle     string
	RecommendationReason  string
}

type PreparedResult struct {
	Profile    PreparedProfile
	Candidates []PreparedCandidate
	Mappings   []PreparedSessionMapping
}

var ErrPreparedLibraryUnavailable = errors.New("recommend: prepared library unavailable")

const maxPreparedCandidatePool = 750

// PrepareForYou performs the expensive work outside a television request. It consumes
// locally imported Tracearr sessions and the complete imported catalogue, producing a
// compact profile and an intentionally over-provisioned ranked pool.
func (e *Engine) PrepareForYou(
	ctx context.Context,
	cred emby.Credentials,
	username string,
	sessions []tracearr.Session,
) (PreparedResult, error) {
	sessions = recommendationSessions(sessions)
	history, favorites, err := e.gatherSignals(ctx, cred)
	if err != nil {
		return PreparedResult{}, err
	}
	profile := BuildProfile(history, favorites)

	library, ok := e.Library.(PreparedLibrarySource)
	if !ok {
		return PreparedResult{}, ErrPreparedLibraryUnavailable
	}
	raws, err := library.AllRecommendationCandidates(ctx)
	if err != nil {
		return PreparedResult{}, err
	}
	catalogue := Decode(raws)
	if len(catalogue) == 0 {
		return PreparedResult{}, ErrPreparedLibraryUnavailable
	}

	browsed := map[string]bool{}
	if e.Behavior != nil {
		browsedRaws, browseErr := e.Behavior.BrowsingCandidates(
			ctx, cred.UserID, time.Now().Add(-30*24*time.Hour), 30,
		)
		if browseErr != nil {
			e.log.Warn("browsing signals unavailable during For You rebuild", "error", browseErr)
		} else {
			for i, item := range Decode(browsedRaws) {
				profile.absorbTaste(item, 0.55*powDecay(0.92, i))
				browsed[item.ID] = true
			}
		}
	}

	index := newCatalogueIndex(catalogue)
	evidenceByGenre := map[string][]PreparedEvidence{}
	evidenceSeen := map[string]bool{}
	addCompletedEvidence := func(item Item, sessionID string) {
		itemID := item.ID
		title := item.Name
		if strings.EqualFold(item.Type, "Episode") &&
			strings.TrimSpace(item.SeriesName) != "" {
			title = item.SeriesName
			if item.SeriesID != "" {
				itemID = item.SeriesID
			}
		}
		for _, genre := range item.Genres {
			genreKey := strings.ToLower(strings.TrimSpace(genre))
			evidenceKey := genreKey + "|" + itemID
			if genreKey == "" || itemID == "" || evidenceSeen[evidenceKey] {
				continue
			}
			evidenceSeen[evidenceKey] = true
			evidenceByGenre[genreKey] = append(
				evidenceByGenre[genreKey],
				PreparedEvidence{
					SessionID: sessionID,
					ItemID:    itemID,
					Title:     title,
					Genres:    append([]string(nil), item.Genres...),
				},
			)
		}
	}
	// Emby's played history is already a trustworthy completion signal and gives the
	// explanation pool breadth even when Tracearr title matching is sparse.
	for _, item := range history {
		if item.UserData.Played {
			addCompletedEvidence(item, "")
		}
	}
	titleAffinity := map[string]PreparedTitleAffinity{}
	mappings := make([]PreparedSessionMapping, 0, len(sessions))
	var completionTotal float64
	durations := make([]int, 0, len(sessions))
	var signalsThrough *time.Time
	tracearrUserID := ""
	tracearrUsername := strings.TrimSpace(username)
	titleSignalCount := map[string]int{}
	contextAffinity := NewContextAffinityProfile()
	weightedEvidence := make([]ViewingEvidence, 0, len(history)+len(favorites)+len(sessions))
	for _, item := range history {
		item = index.evidenceItem(item)
		completion := 0.0
		if item.UserData.Played {
			completion = 1
		} else if item.RunTimeTicks > 0 {
			completion = float64(item.UserData.PlaybackPositionTicks) / float64(item.RunTimeTicks)
		}
		weightedEvidence = append(weightedEvidence, ViewingEvidence{
			Item: item, Completion: completion, Repeat: maxInt(1, item.UserData.PlayCount),
		})
	}
	for _, item := range favorites {
		item = index.evidenceItem(item)
		weightedEvidence = append(weightedEvidence, ViewingEvidence{
			Item: item, Completion: 0, Favorite: true,
		})
	}

	for i, session := range sessions {
		completion := session.Completion()
		completionTotal += completion
		if minutes := int(int64(session.DurationMs) / 60_000); minutes > 0 {
			durations = append(durations, minutes)
		}
		if started, ok := parseTracearrTime(session.StartedAt); ok &&
			(signalsThrough == nil || started.After(*signalsThrough)) {
			value := started
			signalsThrough = &value
		}
		if tracearrUserID == "" {
			tracearrUserID = strings.TrimSpace(session.User.ID)
		}
		if tracearrUsername == "" {
			tracearrUsername = strings.TrimSpace(session.User.Username)
		}
		if completion > 0 {
			profile.SeenTitles[tracearrSeenKey(session)] = true
		}

		item, matched := index.match(session)
		if !matched {
			continue
		}
		seriesID := item.SeriesID
		if strings.EqualFold(session.MediaType, "episode") &&
			strings.EqualFold(item.Type, "Series") {
			seriesID = item.ID
		}
		mappings = append(mappings, PreparedSessionMapping{
			ServerID: session.ServerID, SessionID: session.ID,
			ItemID: item.ID, SeriesID: seriesID,
		})

		// Episode-heavy programmes should be strong signals, but not dozens of
		// independent votes. Each repeat contributes less than the previous one.
		repeats := titleSignalCount[item.ID]
		titleSignalCount[item.ID] = repeats + 1
		weight := (0.2 + completion) * math.Pow(0.985, float64(i)) *
			math.Pow(0.65, float64(repeats))
		profile.absorbTaste(item, weight)
		if completion > 0 {
			if item.ID != "" {
				profile.Seen[item.ID] = true
			}
			if item.SeriesID != "" {
				profile.Seen[item.SeriesID] = true
			}
		}
		current := titleAffinity[item.ID]
		current.Weight += weight
		current.Title = item.Name
		if current.SessionID == "" {
			current.SessionID = session.ID
		}
		titleAffinity[item.ID] = current
		if started, ok := parseTracearrTime(session.StartedAt); ok {
			contextAffinity.Add(item, started, completion, i, e.Location)
			weightedEvidence = append(weightedEvidence, ViewingEvidence{
				Item: item, Completion: completion, Repeat: repeats + 1,
				OccurredAt: started, SessionMinutes: int(int64(session.DurationMs) / 60_000),
			})
		} else {
			weightedEvidence = append(weightedEvidence, ViewingEvidence{
				Item: item, Completion: completion, Repeat: repeats + 1,
				SessionMinutes: int(int64(session.DurationMs) / 60_000),
			})
		}

		if completion >= 0.9 {
			addCompletedEvidence(item, session.ID)
		}
	}

	compatibility := buildCompatibilityProfile(sessions)
	type scored struct {
		item          Item
		base          float64
		compatibility float64
		score         float64
	}
	ranked := make([]scored, 0, len(catalogue))
	seenCandidates := map[string]bool{}
	for _, candidate := range catalogue {
		if candidate.ID == "" || seenCandidates[candidate.ID] {
			continue
		}
		seenCandidates[candidate.ID] = true
		base := profile.Score(candidate)
		if base < 0 {
			continue
		}
		compatibilityValue := compatibilityScore(candidate, compatibility)
		ranked = append(ranked, scored{
			item: candidate, base: base, compatibility: compatibilityValue,
			score: base + compatibilityValue*1.4,
		})
	}
	sort.SliceStable(ranked, func(i, j int) bool {
		if ranked[i].score != ranked[j].score {
			return ranked[i].score > ranked[j].score
		}
		return ranked[i].item.Name < ranked[j].item.Name
	})

	prepared := make([]PreparedCandidate, 0, min(len(ranked), maxPreparedCandidatePool))
	completedReasonCounts := map[string]int{}
	for rank, entry := range ranked {
		if len(prepared) == maxPreparedCandidatePool {
			break
		}
		reason, label, kind, genre, evidence := explainPreparedRecommendation(
			profile, entry.item, compatibility, browsed[entry.item.ID], evidenceByGenre,
			completedReasonCounts,
		)
		prepared = append(prepared, PreparedCandidate{
			ItemID:                entry.item.ID,
			BaseRank:              rank + 1,
			BaseScore:             entry.score,
			RuntimeMinutes:        entry.item.RuntimeMinutes(),
			AffinityScore:         entry.base,
			CompatibilityScore:    entry.compatibility,
			CompatibilityLabel:    label,
			ReasonKind:            kind,
			ReasonGenre:           genre,
			ReasonSourceSessionID: evidence.SessionID,
			ReasonSourceItemID:    evidence.ItemID,
			ReasonSourceTitle:     evidence.Title,
			RecommendationReason:  reason,
		})
	}
	pickups := e.prepareAbandonedShows(ctx, cred, index, sessions, compatibility, time.Now())
	if len(pickups) > 0 {
		for i := range prepared {
			prepared[i].BaseRank += len(pickups)
		}
		for i := range pickups {
			pickups[i].BaseRank = i + 1
		}
		prepared = append(pickups, prepared...)
		if len(prepared) > maxPreparedCandidatePool {
			prepared = prepared[:maxPreparedCandidatePool]
		}
	}

	meanCompletion := 0.0
	if len(sessions) > 0 {
		meanCompletion = completionTotal / float64(len(sessions))
	}
	codecs := map[string]map[string]int{
		"direct":    compatibility.directCodecs,
		"transcode": compatibility.transcodeCodecs,
	}
	return PreparedResult{
		Profile: PreparedProfile{
			TracearrUserID: tracearrUserID, TracearrUsername: tracearrUsername,
			SourceSessionCount: len(sessions), MeanCompletionRatio: meanCompletion,
			TypicalSessionMinutes: medianInt(durations),
			GenreAffinity:         profile.GenreWeights, TitleAffinity: titleAffinity,
			StudioAffinity: profile.StudioWeights, ContextAffinity: contextAffinity,
			CodecOutcomes: codecs,
			Weighted: BuildWeightedProfileWithConfig(
				weightedEvidence, time.Now(), e.Location, e.WeightedConfig,
			),
			SignalsThrough: signalsThrough,
		},
		Candidates: prepared,
		Mappings:   mappings,
	}, nil
}

const (
	abandonedShowAge = 21 * 24 * time.Hour
	maxPickupShows   = 20
)

type abandonedShowProgress struct {
	item              Item
	lastActivity      time.Time
	lastSeason        int
	completedEpisodes map[string]bool
}

// prepareAbandonedShows adds watched series back into the otherwise-unwatched candidate
// pool. Emby Next Up is the completion boundary: if Emby has no next episode for this
// user, the show is complete and cannot appear here.
func (e *Engine) prepareAbandonedShows(
	ctx context.Context,
	cred emby.Credentials,
	index catalogueIndex,
	sessions []tracearr.Session,
	compatibility compatibilityProfile,
	now time.Time,
) []PreparedCandidate {
	nextUpSource, ok := e.source.(NextUpSource)
	if !ok {
		return nil
	}
	result, err := nextUpSource.NextUp(ctx, cred, url.Values{
		"Limit":                  {"5000"},
		"Fields":                 {"SeriesName,SeriesId,ParentIndexNumber,IndexNumber,RunTimeTicks"},
		"EnableImages":           {"false"},
		"EnableUserData":         {"true"},
		"EnableTotalRecordCount": {"false"},
	})
	if err != nil {
		e.log.Warn("could not check abandoned shows against Emby Next Up", "error", err)
		return nil
	}
	return abandonedShowCandidates(index, sessions, Decode(result.Items), compatibility, now)
}

func abandonedShowCandidates(
	index catalogueIndex,
	sessions []tracearr.Session,
	nextUp []Item,
	compatibility compatibilityProfile,
	now time.Time,
) []PreparedCandidate {
	progress := map[string]*abandonedShowProgress{}
	for _, session := range sessions {
		if !strings.EqualFold(session.MediaType, "episode") ||
			strings.TrimSpace(session.ShowTitle) == "" ||
			session.Completion() < 0.1 {
			continue
		}
		series, matched := index.match(session)
		if !matched || series.ID == "" || !strings.EqualFold(series.Type, "Series") {
			continue
		}
		activity, ok := tracearrActivityTime(session)
		if !ok {
			continue
		}
		current := progress[series.ID]
		if current == nil {
			current = &abandonedShowProgress{
				item: series, completedEpisodes: map[string]bool{},
			}
			progress[series.ID] = current
		}
		if activity.After(current.lastActivity) {
			current.lastActivity = activity
		}
		// Track the furthest season ever reached, not merely the season from the most
		// recent replay. Any season-two evidence disqualifies a first-season pickup.
		if session.SeasonNumber != nil && *session.SeasonNumber > current.lastSeason {
			current.lastSeason = *session.SeasonNumber
		}
		if session.Completion() >= 0.9 && session.SeasonNumber != nil &&
			session.EpisodeNumber != nil {
			key := strconv.Itoa(*session.SeasonNumber) + ":" + strconv.Itoa(*session.EpisodeNumber)
			current.completedEpisodes[key] = true
		}
	}

	nextBySeries := map[string]Item{}
	for _, episode := range nextUp {
		// Specials do not mean the main programme is unfinished.
		if episode.SeriesID == "" || episode.ParentIndexNumber <= 0 {
			continue
		}
		if _, exists := nextBySeries[episode.SeriesID]; !exists {
			nextBySeries[episode.SeriesID] = episode
		}
	}

	type pickup struct {
		progress *abandonedShowProgress
		next     Item
	}
	eligible := make([]pickup, 0, len(progress))
	cutoff := now.Add(-abandonedShowAge)
	for seriesID, watched := range progress {
		next, unfinished := nextBySeries[seriesID]
		// This shelf is intentionally about promising shows abandoned in or just after
		// their first season. Later-season lapses are ordinary Next Up material. Some
		// Tracearr episode records lack a season number, so a season-one Next Up is also
		// sufficient evidence that the viewer is still at the beginning.
		firstSeasonAbandonment := watched.lastSeason == 1 &&
			next.ParentIndexNumber <= 2 ||
			watched.lastSeason == 0 && next.ParentIndexNumber == 1
		if !unfinished || watched.lastActivity.After(cutoff) || !firstSeasonAbandonment {
			continue
		}
		eligible = append(eligible, pickup{progress: watched, next: next})
	}
	sort.SliceStable(eligible, func(i, j int) bool {
		iLaterSeason := eligible[i].progress.lastSeason > 1
		jLaterSeason := eligible[j].progress.lastSeason > 1
		if iLaterSeason != jLaterSeason {
			return iLaterSeason
		}
		if !eligible[i].progress.lastActivity.Equal(eligible[j].progress.lastActivity) {
			return eligible[i].progress.lastActivity.After(eligible[j].progress.lastActivity)
		}
		return len(eligible[i].progress.completedEpisodes) >
			len(eligible[j].progress.completedEpisodes)
	})
	if len(eligible) > maxPickupShows {
		eligible = eligible[:maxPickupShows]
	}

	out := make([]PreparedCandidate, 0, len(eligible))
	for _, candidate := range eligible {
		watched := candidate.progress
		next := candidate.next
		reason := abandonedShowReason(watched.lastSeason, next.ParentIndexNumber)
		compatibilityValue := compatibilityScore(watched.item, compatibility)
		out = append(out, PreparedCandidate{
			ItemID:               watched.item.ID,
			RuntimeMinutes:       next.RuntimeMinutes(),
			BaseScore:            float64(len(watched.completedEpisodes)),
			AffinityScore:        float64(len(watched.completedEpisodes)),
			CompatibilityScore:   compatibilityValue,
			CompatibilityLabel:   compatibilityLabelForScore(compatibilityValue),
			ReasonKind:           "pick-up",
			ReasonSourceItemID:   next.ID,
			ReasonSourceTitle:    next.Name,
			RecommendationReason: reason,
		})
	}
	return out
}

func tracearrActivityTime(session tracearr.Session) (time.Time, bool) {
	if stopped, ok := parseTracearrTime(session.StoppedAt); ok {
		return stopped, true
	}
	return parseTracearrTime(session.StartedAt)
}

func abandonedShowReason(lastSeason, nextSeason int) string {
	switch {
	case lastSeason == 1 && nextSeason > 1:
		return fmt.Sprintf("You finished season 1 · season %d is waiting", nextSeason)
	case lastSeason > 1 && nextSeason > lastSeason:
		return fmt.Sprintf("You made it through season %d · season %d is waiting", lastSeason, nextSeason)
	case lastSeason > 1:
		return fmt.Sprintf("You made it to season %d · pick it up again", lastSeason)
	case lastSeason == 1 || lastSeason == 0 && nextSeason == 1:
		return "You left this in season 1 · pick it up again"
	default:
		return "You left this unfinished · pick it up again"
	}
}

func compatibilityLabelForScore(score float64) string {
	switch {
	case score > 0.2:
		return "Direct plays well on this TV"
	case score < -0.2:
		return "May need transcoding on this TV"
	default:
		return "TV compatibility not yet learned"
	}
}

func explainPreparedRecommendation(
	profile Profile,
	item Item,
	compatibility compatibilityProfile,
	browsed bool,
	evidenceByGenre map[string][]PreparedEvidence,
	completedReasonCounts map[string]int,
) (reason, label, kind, genre string, evidence PreparedEvidence) {
	for _, wanted := range profile.TopGenres(5) {
		for _, candidateGenre := range item.Genres {
			if strings.EqualFold(wanted, candidateGenre) {
				genre = candidateGenre
				options := evidenceByGenre[strings.ToLower(strings.TrimSpace(wanted))]
				strong := make([]PreparedEvidence, 0, len(options))
				for _, option := range options {
					if strongEvidenceMatch(item, option) {
						strong = append(strong, option)
					}
				}
				if len(strong) > 0 {
					evidence = strong[stableEvidenceIndex(item.ID, len(strong))]
				}
				break
			}
		}
		if genre != "" {
			break
		}
	}
	// Keep specific evidence prominent without letting it monopolise a row. One third
	// of otherwise eligible cards deliberately uses the broader genre explanation,
	// and no completed title can explain more than four candidates in a prepared pool.
	useCompleted := evidence.Title != "" &&
		stableEvidenceIndex("reason-kind:"+item.ID, 3) != 0 &&
		completedReasonCounts[evidence.ItemID] < 4
	switch {
	case browsed:
		reason, kind = "You explored this recently", "browsed"
		evidence = PreparedEvidence{}
	case useCompleted:
		reason, kind = "Because you finished "+evidence.Title, "completed-title"
		completedReasonCounts[evidence.ItemID]++
	case genre != "":
		reason, kind = "Matches your "+genre+" viewing", "genre"
		evidence = PreparedEvidence{}
	case len(profile.Seeds) > 0:
		reason, kind = "Inspired by "+profile.Seeds[0].Name, "recent-title"
		evidence = PreparedEvidence{}
	default:
		reason, kind = "Matches your recent viewing", "generic"
		evidence = PreparedEvidence{}
	}
	switch score := compatibilityScore(item, compatibility); {
	case score > 0.2:
		label = "Direct plays well on this TV"
		reason += " · " + label
	case score < -0.2:
		label = "May need transcoding on this TV"
	default:
		label = "TV compatibility not yet learned"
	}
	return reason, label, kind, genre, evidence
}

func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool {
	shared := 0
	broadOnly := true
	for _, candidateGenre := range item.Genres {
		for _, evidenceGenre := range evidence.Genres {
			if !strings.EqualFold(strings.TrimSpace(candidateGenre), strings.TrimSpace(evidenceGenre)) {
				continue
			}
			shared++
			switch strings.ToLower(strings.TrimSpace(candidateGenre)) {
			case "action", "adventure", "comedy", "drama", "thriller":
			default:
				broadOnly = false
			}
			break
		}
	}
	return shared >= 2 || shared == 1 && !broadOnly
}

func stableEvidenceIndex(itemID string, size int) int {
	if size <= 1 {
		return 0
	}
	var hash uint32 = 2166136261
	for _, value := range []byte(itemID) {
		hash ^= uint32(value)
		hash *= 16777619
	}
	return int(hash % uint32(size))
}

type catalogueIndex struct {
	movieExact map[string]Item
	movieLoose map[string]Item
	series     map[string]Item
	byID       map[string]Item
	ambiguous  map[string]bool
}

func newCatalogueIndex(items []Item) catalogueIndex {
	index := catalogueIndex{
		movieExact: map[string]Item{}, movieLoose: map[string]Item{},
		series: map[string]Item{}, byID: map[string]Item{}, ambiguous: map[string]bool{},
	}
	for _, item := range items {
		index.byID[item.ID] = item
		key := normalizePreparedTitle(item.Name)
		switch item.Type {
		case "Movie":
			if item.ProductionYear > 0 {
				index.movieExact[key+"|"+itoa(item.ProductionYear)] = item
			}
			if _, exists := index.movieLoose[key]; exists {
				index.ambiguous["movie|"+key] = true
			} else {
				index.movieLoose[key] = item
			}
		case "Series":
			if _, exists := index.series[key]; exists {
				index.ambiguous["series|"+key] = true
			} else {
				index.series[key] = item
			}
		}
	}
	return index
}

// evidenceItem promotes episode evidence to its series metadata. Emby episode rows often
// omit People and studio details even when those fields were requested, while the parent
// Series record contains the canonical cast and production metadata.
func (i catalogueIndex) evidenceItem(item Item) Item {
	if !strings.EqualFold(item.Type, "Episode") || item.SeriesID == "" {
		return item
	}
	parent, ok := i.byID[item.SeriesID]
	if !ok {
		return item
	}
	parent.UserData = item.UserData
	return parent
}

func (i catalogueIndex) match(session tracearr.Session) (Item, bool) {
	if strings.EqualFold(session.MediaType, "episode") && strings.TrimSpace(session.ShowTitle) != "" {
		key := normalizePreparedTitle(session.ShowTitle)
		if i.ambiguous["series|"+key] {
			return Item{}, false
		}
		item, ok := i.series[key]
		return item, ok
	}
	key := normalizePreparedTitle(session.MediaTitle)
	if session.Year != nil && *session.Year > 0 {
		if item, ok := i.movieExact[key+"|"+itoa(*session.Year)]; ok {
			return item, true
		}
	}
	if i.ambiguous["movie|"+key] {
		return Item{}, false
	}
	item, ok := i.movieLoose[key]
	return item, ok
}

func normalizePreparedTitle(value string) string {
	var b strings.Builder
	for _, r := range strings.ToLower(value) {
		if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
			b.WriteRune(r)
		}
	}
	return b.String()
}

func parseTracearrTime(value string) (time.Time, bool) {
	parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value))
	if err != nil {
		return time.Time{}, false
	}
	return parsed.UTC(), true
}

func medianInt(values []int) int {
	if len(values) == 0 {
		return 0
	}
	copyValues := append([]int(nil), values...)
	sort.Ints(copyValues)
	mid := len(copyValues) / 2
	if len(copyValues)%2 == 1 {
		return copyValues[mid]
	}
	return (copyValues[mid-1] + copyValues[mid]) / 2
}

func itoa(value int) string {
	if value == 0 {
		return "0"
	}
	var buf [20]byte
	pos := len(buf)
	for value > 0 {
		pos--
		buf[pos] = byte('0' + value%10)
		value /= 10
	}
	return string(buf[pos:])
}
