package tools

import (
	"fmt"
	"math/rand"
	"os"
	"path/filepath"
	"slices"
	"sort"
	"strings"
	"time"

	"skraak/utils"
)

// KeyBinding maps a key to a species/calltype
type KeyBinding struct {
	Key      string // single char: "k", "n", "p"
	Species  string // "Kiwi", "Don't Know", "Morepork"
	CallType string // "Duet", "Female", "Male" (optional)
}

// ClassifyConfig holds the configuration for classification
type ClassifyConfig struct {
	Folder    string
	File      string
	Filter    string
	Species   string // scope to this species (optional)
	CallType  string // scope to this calltype within species (optional)
	Certainty int    // scope to this certainty value, -1 = no filter (optional)
	Sample    int    // random sample percentage 1-99, -1 = no sampling, 100 = no-op
	Goto      string // goto this file on startup (optional, basename match)
	Reviewer  string
	Color     bool
	ImageSize int // spectrogram display size in pixels (0 = default)
	Sixel     bool
	ITerm     bool
	Bindings  []KeyBinding
	// SecondaryBindings maps a primary binding key to per-species calltype
	// keys. Invoked via Shift+primary-key: the species is labeled without
	// advancing, and the next key is interpreted as a calltype.
	SecondaryBindings map[string]map[string]string
	Night             bool
	Day               bool
	Lat               float64
	Lng               float64
	Timezone          string
}

// ClassifyState holds the current state for TUI
type ClassifyState struct {
	Config            ClassifyConfig
	DataFiles         []*utils.DataFile
	filteredSegs      [][]*utils.Segment // cached at load time, parallel to DataFiles
	totalSegs         int                // pre-computed total segment count
	FileIdx           int
	SegmentIdx        int
	Dirty             bool
	Player            *utils.AudioPlayer
	PlaybackSpeed     float64 // Current playback speed (1.0 = normal, 0.5 = half speed)
	TimeFilteredCount int     // files skipped by --night or --day filter
}

// BindingResult represents parsed key result
type BindingResult struct {
	Species  string
	CallType string // empty string = remove calltype
}

// LoadDataFiles loads all .data files for classification
// findDataFilePaths resolves the list of .data file paths from config.
func findDataFilePaths(config ClassifyConfig) ([]string, error) {
	if config.File != "" {
		return []string{config.File}, nil
	}
	paths, err := utils.FindDataFiles(config.Folder)
	if err != nil {
		return nil, fmt.Errorf("find data files: %w", err)
	}
	return paths, nil
}

// filterDataFileSegments applies segment and day/night filters to a single data file.
// Returns the filtered segments and whether the file should be kept.
// If the file is filtered out (no matching segments, or time-of-day), returns nil, false.
func filterDataFileSegments(df *utils.DataFile, config ClassifyConfig) ([]*utils.Segment, bool, int) {
	segs := filterSegmentsByLabel(df.Segments, config)
	if segs == nil {
		return nil, false, 0
	}

	timeFiltered := 0
	if config.Night || config.Day {
		keep, tf := filterByTimeOfDay(df.FilePath, config)
		if !keep {
			return nil, false, tf
		}
	}
	return segs, true, timeFiltered
}

// filterSegmentsByLabel applies label/species/certainty filters, returning matching segments.
// Returns nil if no segments match (caller should skip the file).
func filterSegmentsByLabel(segments []*utils.Segment, config ClassifyConfig) []*utils.Segment {
	hasFilter := config.Filter != "" || config.Species != "" || config.Certainty >= 0
	if !hasFilter {
		return segments
	}
	var segs []*utils.Segment
	for _, seg := range segments {
		if seg.SegmentMatchesFilters(config.Filter, config.Species, config.CallType, config.Certainty) {
			segs = append(segs, seg)
		}
	}
	return segs // nil if empty, caller treats as "skip"
}

// filterByTimeOfDay checks --night/--day time-of-day filter for a .data file.
// Returns (keep, timeFilteredCount).
func filterByTimeOfDay(dataFilePath string, config ClassifyConfig) (bool, int) {
	wavPath := filepath.Clean(strings.TrimSuffix(dataFilePath, ".data"))
	result, err := IsNight(IsNightInput{
		FilePath: wavPath,
		Lat:      config.Lat,
		Lng:      config.Lng,
		Timezone: config.Timezone,
	})
	if err != nil {
		fmt.Fprintf(os.Stderr, "warning: skipping %s (isnight error: %v)\n", wavPath, err)
		return false, 1
	}
	if config.Night && !result.SolarNight {
		return false, 1
	}
	if config.Day && !result.DiurnalActive {
		return false, 1
	}
	return true, 0
}

func LoadDataFiles(config ClassifyConfig) (*ClassifyState, error) {
	dataFiles, err := parseAndSortDataFiles(config)
	if err != nil {
		return nil, err
	}

	kept, cachedSegs, timeFiltered := filterDataFiles(dataFiles, config)

	if config.Sample > 0 && config.Sample < 100 {
		rng := rand.New(rand.NewSource(time.Now().UnixNano()))
		kept, cachedSegs = applySampling(kept, cachedSegs, config.Sample, rng)
	}

	return buildClassifyState(config, kept, cachedSegs, timeFiltered)
}

// parseAndSortDataFiles finds, parses, and sorts .data files from the config.
func parseAndSortDataFiles(config ClassifyConfig) ([]*utils.DataFile, error) {
	filePaths, err := findDataFilePaths(config)
	if err != nil {
		return nil, err
	}
	if len(filePaths) == 0 {
		return nil, fmt.Errorf("no .data files found")
	}

	var dataFiles []*utils.DataFile
	for _, path := range filePaths {
		df, err := utils.ParseDataFile(path)
		if err != nil {
			continue
		}
		dataFiles = append(dataFiles, df)
	}
	if len(dataFiles) == 0 {
		return nil, fmt.Errorf("no valid .data files")
	}

	sort.Slice(dataFiles, func(i, j int) bool {
		return dataFiles[i].FilePath < dataFiles[j].FilePath
	})

	return dataFiles, nil
}

// filterDataFiles applies segment filters to each data file, returning kept files and their segments.
func filterDataFiles(dataFiles []*utils.DataFile, config ClassifyConfig) ([]*utils.DataFile, [][]*utils.Segment, int) {
	var kept []*utils.DataFile
	var cachedSegs [][]*utils.Segment
	var timeFiltered int

	for _, df := range dataFiles {
		segs, keep, tf := filterDataFileSegments(df, config)
		timeFiltered += tf
		if !keep {
			continue
		}
		kept = append(kept, df)
		cachedSegs = append(cachedSegs, segs)
	}
	return kept, cachedSegs, timeFiltered
}

// buildClassifyState constructs the ClassifyState, handling --goto file positioning.
func buildClassifyState(config ClassifyConfig, dataFiles []*utils.DataFile, filteredSegs [][]*utils.Segment, timeFiltered int) (*ClassifyState, error) {
	total := 0
	for _, segs := range filteredSegs {
		total += len(segs)
	}

	state := &ClassifyState{
		Config:            config,
		DataFiles:         dataFiles,
		filteredSegs:      filteredSegs,
		totalSegs:         total,
		TimeFilteredCount: timeFiltered,
	}

	if config.Goto == "" {
		return state, nil
	}

	for i, df := range state.DataFiles {
		base := df.FilePath[strings.LastIndex(df.FilePath, "/")+1:]
		if base == config.Goto {
			state.FileIdx = i
			return state, nil
		}
	}
	return nil, fmt.Errorf("goto file not found (or has no matching segments): %s", config.Goto)
}

// applySampling randomly selects sample% of segments from the filtered set.
// The returned files and segments preserve the original chronological order.
func applySampling(kept []*utils.DataFile, cachedSegs [][]*utils.Segment, sample int, rng *rand.Rand) ([]*utils.DataFile, [][]*utils.Segment) {
	flat := make([]struct{ fileIdx, segIdx int }, 0)
	for fi, segs := range cachedSegs {
		for si := range segs {
			flat = append(flat, struct{ fileIdx, segIdx int }{fi, si})
		}
	}

	targetCount := max(len(flat)*sample/100, 1)

	rng.Shuffle(len(flat), func(i, j int) { flat[i], flat[j] = flat[j], flat[i] })
	selected := flat[:targetCount]

	// Restore chronological order before rebuilding
	sort.Slice(selected, func(i, j int) bool {
		if selected[i].fileIdx != selected[j].fileIdx {
			return selected[i].fileIdx < selected[j].fileIdx
		}
		return selected[i].segIdx < selected[j].segIdx
	})

	newCached := make([][]*utils.Segment, len(cachedSegs))
	for _, ref := range selected {
		newCached[ref.fileIdx] = append(newCached[ref.fileIdx], cachedSegs[ref.fileIdx][ref.segIdx])
	}

	var newKept []*utils.DataFile
	var finalCached [][]*utils.Segment
	for i, segs := range newCached {
		if len(segs) > 0 {
			newKept = append(newKept, kept[i])
			finalCached = append(finalCached, segs)
		}
	}
	return newKept, finalCached
}

// FilteredSegs returns the cached filtered segments parallel to DataFiles.
func (s *ClassifyState) FilteredSegs() [][]*utils.Segment {
	return s.filteredSegs
}

// CurrentFile returns the current data file
func (s *ClassifyState) CurrentFile() *utils.DataFile {
	if s.FileIdx >= len(s.DataFiles) {
		return nil
	}
	return s.DataFiles[s.FileIdx]
}

// CurrentSegment returns the current segment
func (s *ClassifyState) CurrentSegment() *utils.Segment {
	if s.FileIdx >= len(s.filteredSegs) {
		return nil
	}
	segs := s.filteredSegs[s.FileIdx]
	if s.SegmentIdx >= len(segs) {
		return nil
	}
	return segs[s.SegmentIdx]
}

// TotalSegments returns total segments to review
func (s *ClassifyState) TotalSegments() int {
	return s.totalSegs
}

// CurrentSegmentNumber returns 1-based segment number
func (s *ClassifyState) CurrentSegmentNumber() int {
	count := 0
	for i := 0; i < s.FileIdx; i++ {
		count += len(s.filteredSegs[i])
	}
	return count + s.SegmentIdx + 1
}

// NextSegment moves to the next segment, returns false if at end
func (s *ClassifyState) NextSegment() bool {
	if s.FileIdx >= len(s.filteredSegs) {
		return false
	}

	segs := s.filteredSegs[s.FileIdx]
	if s.SegmentIdx+1 < len(segs) {
		s.SegmentIdx++
		return true
	}

	// Move to next file
	if s.FileIdx+1 < len(s.DataFiles) {
		s.FileIdx++
		s.SegmentIdx = 0
		return true
	}

	return false
}

// PrevSegment moves to the previous segment, returns false if at start
func (s *ClassifyState) PrevSegment() bool {
	if s.SegmentIdx > 0 {
		s.SegmentIdx--
		return true
	}

	// Move to previous file
	if s.FileIdx > 0 {
		s.FileIdx--
		segs := s.filteredSegs[s.FileIdx]
		s.SegmentIdx = max(len(segs)-1, 0)
		return true
	}

	return false
}

// ParseKeyBuffer parses a single key into binding result
func (s *ClassifyState) ParseKeyBuffer(key string) *BindingResult {
	for _, b := range s.Config.Bindings {
		if b.Key == key {
			return &BindingResult{
				Species:  b.Species,
				CallType: b.CallType,
			}
		}
	}
	return nil
}

// SetComment sets the comment on the current segment's filter label.
// Returns the previous comment (for undo) or empty string if none.
func (s *ClassifyState) SetComment(comment string) string {
	seg := s.CurrentSegment()
	if seg == nil {
		return ""
	}

	df := s.CurrentFile()
	if df == nil {
		return ""
	}

	// Set reviewer
	df.Meta.Reviewer = s.Config.Reviewer

	// Get labels matching filter
	filterLabels := seg.GetFilterLabels(s.Config.Filter)

	var oldComment string
	if len(filterLabels) == 0 {
		// No matching labels, add new one with comment
		label := &utils.Label{
			Species:   "Don't Know",
			Certainty: 0,
			Filter:    s.Config.Filter,
			Comment:   comment,
		}
		seg.Labels = append(seg.Labels, label)
	} else {
		// Set comment on first matching label
		oldComment = filterLabels[0].Comment
		filterLabels[0].Comment = comment
	}

	s.Dirty = true
	return oldComment
}

// GetCurrentComment returns the comment on the current segment's filter label.
func (s *ClassifyState) GetCurrentComment() string {
	seg := s.CurrentSegment()
	if seg == nil {
		return ""
	}

	filterLabels := seg.GetFilterLabels(s.Config.Filter)
	if len(filterLabels) == 0 {
		return ""
	}
	return filterLabels[0].Comment
}

// ApplyBinding applies a binding result to the current segment
func (s *ClassifyState) ApplyBinding(result *BindingResult) {
	seg := s.CurrentSegment()
	if seg == nil {
		return
	}

	df := s.CurrentFile()
	if df == nil {
		return
	}

	// Set reviewer
	df.Meta.Reviewer = s.Config.Reviewer

	// Get labels matching filter
	filterLabels := seg.GetFilterLabels(s.Config.Filter)

	// Determine certainty: 0 for Don't Know, 100 for others
	certainty := 100
	if result.Species == "Don't Know" {
		certainty = 0
	}

	if len(filterLabels) == 0 {
		// No matching labels, add new one
		seg.Labels = append(seg.Labels, &utils.Label{
			Species:   result.Species,
			Certainty: certainty,
			Filter:    s.Config.Filter,
			CallType:  result.CallType,
		})
	} else {
		// Edit first matching label, remove rest
		filterLabels[0].Species = result.Species
		filterLabels[0].Certainty = certainty
		filterLabels[0].CallType = result.CallType // always set (empty = remove)

		// Remove extra matching labels
		if len(filterLabels) > 1 {
			var newLabels []*utils.Label
			for _, l := range seg.Labels {
				keep := !slices.Contains(filterLabels[1:], l)
				if keep {
					newLabels = append(newLabels, l)
				}
			}
			seg.Labels = newLabels
		}
	}

	// Re-sort labels
	sort.Slice(seg.Labels, func(i, j int) bool {
		return seg.Labels[i].Species < seg.Labels[j].Species
	})

	s.Dirty = true
}

// ApplyCallTypeOnly sets the CallType on the current segment's first
// filter-matching label. Used after a Shift+primary keypress labeled the
// species and we now receive the secondary key for the calltype.
// No-op if there is no matching label to update.
func (s *ClassifyState) ApplyCallTypeOnly(callType string) {
	seg := s.CurrentSegment()
	if seg == nil {
		return
	}
	df := s.CurrentFile()
	if df == nil {
		return
	}
	filterLabels := seg.GetFilterLabels(s.Config.Filter)
	if len(filterLabels) == 0 {
		return
	}
	df.Meta.Reviewer = s.Config.Reviewer
	filterLabels[0].CallType = callType
	s.Dirty = true
}

// HasSecondary reports whether the given primary key has any secondary
// (calltype) bindings configured.
func (s *ClassifyState) HasSecondary(primaryKey string) bool {
	return len(s.Config.SecondaryBindings[primaryKey]) > 0
}

// ConfirmLabel upgrades the current segment's existing filter label certainty
// to 100. Returns true if a write is needed (label existed and was below 100).
// Returns false for Don't Know (certainty=0) — confirming a Don't Know is a no-op;
// the caller should just advance to the next segment.
func (s *ClassifyState) ConfirmLabel() bool {
	seg := s.CurrentSegment()
	if seg == nil {
		return false
	}
	filterLabels := seg.GetFilterLabels(s.Config.Filter)
	if len(filterLabels) == 0 {
		return false
	}
	if filterLabels[0].Certainty == 0 {
		return false
	}
	if filterLabels[0].Certainty == 100 {
		return false
	}
	df := s.CurrentFile()
	if df == nil {
		return false
	}
	df.Meta.Reviewer = s.Config.Reviewer
	filterLabels[0].Certainty = 100
	s.Dirty = true
	return true
}

// Save saves the current file
func (s *ClassifyState) Save() error {
	df := s.CurrentFile()
	if df == nil {
		return nil
	}

	if !s.Dirty {
		return nil
	}

	err := df.Write(df.FilePath)
	if err != nil {
		return err
	}

	s.Dirty = false
	return nil
}

// getFilterLabel returns the label matching the current filter, or first label if no filter.
func (s *ClassifyState) getFilterLabel(seg *utils.Segment) *utils.Label {
	if s.Config.Filter == "" {
		if len(seg.Labels) > 0 {
			return seg.Labels[0]
		}
		return nil
	}
	for _, label := range seg.Labels {
		if label.Filter == s.Config.Filter {
			return label
		}
	}
	return nil
}

// getOrCreateFilterLabel gets existing label or creates new one for the current filter.
func (s *ClassifyState) getOrCreateFilterLabel(seg *utils.Segment) *utils.Label {
	label := s.getFilterLabel(seg)
	if label != nil {
		return label
	}
	// Create new label
	label = &utils.Label{
		Species:   "Don't Know",
		Certainty: 0,
		Filter:    s.Config.Filter,
	}
	seg.Labels = append(seg.Labels, label)
	s.Dirty = true
	return label
}

// HasBookmark returns true if current segment has a bookmark on the filter label.
func (s *ClassifyState) HasBookmark() bool {
	seg := s.CurrentSegment()
	if seg == nil {
		return false
	}
	label := s.getFilterLabel(seg)
	return label != nil && label.Bookmark
}

// ToggleBookmark toggles the bookmark on the current segment's filter label.
func (s *ClassifyState) ToggleBookmark() {
	seg := s.CurrentSegment()
	if seg == nil {
		return
	}

	df := s.CurrentFile()
	if df == nil {
		return
	}

	// Set reviewer
	df.Meta.Reviewer = s.Config.Reviewer

	label := s.getOrCreateFilterLabel(seg)
	label.Bookmark = !label.Bookmark
	s.Dirty = true
}

// NextBookmark navigates to the next bookmark, wrapping around if needed.
// Returns false if no bookmarks found (back at start position).
func (s *ClassifyState) NextBookmark() bool {
	startFile := s.FileIdx
	startSeg := s.SegmentIdx
	first := true

	for {
		// Advance to next segment
		if !s.NextSegment() {
			// Wrap to start of folder
			s.FileIdx = 0
			s.SegmentIdx = 0
		}

		// Check if we've looped back to start
		if !first && s.FileIdx == startFile && s.SegmentIdx == startSeg {
			return false // full circle, no bookmark found
		}
		first = false

		// Check if current segment has bookmark
		if s.hasFilterBookmark() {
			return true
		}
	}
}

// PrevBookmark navigates to the previous bookmark, wrapping around if needed.
// Returns false if no bookmarks found (back at start position).
func (s *ClassifyState) PrevBookmark() bool {
	startFile := s.FileIdx
	startSeg := s.SegmentIdx
	first := true

	for {
		// Move to previous segment
		if !s.PrevSegment() {
			// Wrap to end of folder
			s.FileIdx = len(s.DataFiles) - 1
			segs := s.filteredSegs[s.FileIdx]
			s.SegmentIdx = max(len(segs)-1, 0)
		}

		// Check if we've looped back to start
		if !first && s.FileIdx == startFile && s.SegmentIdx == startSeg {
			return false // full circle, no bookmark found
		}
		first = false

		// Check if current segment has bookmark
		if s.hasFilterBookmark() {
			return true
		}
	}
}

// hasFilterBookmark checks if current segment has bookmark on filter-matching label.
func (s *ClassifyState) hasFilterBookmark() bool {
	seg := s.CurrentSegment()
	if seg == nil {
		return false
	}
	label := s.getFilterLabel(seg)
	return label != nil && label.Bookmark
}

// FormatLabels formats labels for display
func FormatLabels(labels []*utils.Label, filter string) string {
	var parts []string
	for _, l := range labels {
		if filter != "" && l.Filter != filter {
			continue
		}
		part := l.Species
		if l.CallType != "" {
			part += "/" + l.CallType
		}
		part += fmt.Sprintf(" (%d%%)", l.Certainty)
		if l.Filter != "" {
			part += " [" + l.Filter + "]"
		}
		if l.Comment != "" {
			part += fmt.Sprintf(" \"%s\"", l.Comment)
		}
		parts = append(parts, part)
	}
	return strings.Join(parts, ", ")
}