package input

import (
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"

	tea "github.com/charmbracelet/bubbletea"

	"github.com/genai-io/san/internal/app/kit/history"
	"github.com/genai-io/san/internal/app/kit/suggest"
	"github.com/genai-io/san/internal/core"
	"github.com/genai-io/san/internal/image"
)

const (
	minTextareaHeight    = 1
	defaultMaxHeight     = 10
	fixedChromeLines     = 6 // separators(2) + status(1) + prompt overhead(2) + image/warning(1)
	maxHeightScreenRatio = 2 // use up to 1/2 of terminal height
)

// imageRefPattern matches @path/to/image.ext references (case-insensitive extension).
var imageRefPattern = regexp.MustCompile(`(?i)@([^\s]+\.(png|jpg|jpeg|gif|webp))`)

// ImageTokenMatch describes an inline image token found in the textarea value.
type ImageTokenMatch struct {
	PendingIdx int
	ID         int
	Label      string
	Start      int
	End        int
}

// maxTextareaHeight returns the dynamic max height based on terminal size.
func (m *Model) maxTextareaHeight() int {
	if m.TerminalHeight <= 0 {
		return defaultMaxHeight
	}
	dynMax := m.TerminalHeight/maxHeightScreenRatio - fixedChromeLines
	if dynMax < defaultMaxHeight {
		return defaultMaxHeight
	}
	return dynMax
}

// UpdateHeight adjusts textarea height based on content line count.
func (m *Model) UpdateHeight() {
	content := m.Textarea.Value()
	lines := strings.Count(content, "\n") + 1
	width := m.Textarea.Width()
	if width <= 0 {
		width = 1
	}
	for _, line := range strings.Split(content, "\n") {
		lineWidth := max(len([]rune(line)), 1)
		lines += (lineWidth - 1) / width
	}

	newHeight := max(min(lines, m.maxTextareaHeight()), minTextareaHeight)

	m.Textarea.SetHeight(newHeight)
}

// imageLabel returns the display label for a pending image token.
func imageLabel(id int) string {
	return fmt.Sprintf("[Image #%d]", id)
}

// AddPendingImage appends a new inline image token and returns its label.
func (m *Model) AddPendingImage(img core.Image) string {
	m.Images.NextID++
	m.Images.Pending = append(m.Images.Pending, PendingImage{
		ID:   m.Images.NextID,
		Data: img,
	})
	return imageLabel(m.Images.NextID)
}

// ClearImages resets all inline image state.
func (m *Model) ClearImages() {
	m.Images.Pending = nil
	m.Images.NextID = 0
	m.Images.Selection = ImageSelection{}
}

// CursorIndex returns the absolute rune cursor position in the textarea value.
func (m *Model) CursorIndex() int {
	lines := strings.Split(m.Textarea.Value(), "\n")
	row := m.Textarea.Line()
	if row >= len(lines) {
		row = len(lines) - 1
	}
	if row < 0 {
		return 0
	}

	col := m.Textarea.LineInfo().StartColumn + m.Textarea.LineInfo().ColumnOffset
	idx := 0
	for i := 0; i < row; i++ {
		idx += len([]rune(lines[i])) + 1
	}
	return idx + col
}

// SetCursorIndex moves the cursor to the absolute rune position by replaying
// horizontal cursor movement from the current position.
func (m *Model) SetCursorIndex(target int) {
	valueLen := len([]rune(m.Textarea.Value()))
	target = max(0, min(target, valueLen))
	current := m.CursorIndex()
	if target == current {
		return
	}

	keyType := tea.KeyRight
	steps := target - current
	if target < current {
		keyType = tea.KeyLeft
		steps = current - target
	}

	for i := 0; i < steps; i++ {
		m.stepCursor(keyType)
	}
}

// PendingImageMatches returns inline image token matches in display order.
func (m *Model) PendingImageMatches() []ImageTokenMatch {
	return m.PendingImageMatchesIn(m.Textarea.Value())
}

// PendingImageMatchesIn returns inline image token matches for the provided
// buffer rather than the live textarea contents.
func (m *Model) PendingImageMatchesIn(value string) []ImageTokenMatch {
	valueRunes := []rune(value)
	matches := make([]ImageTokenMatch, 0, len(m.Images.Pending))

	for idx, pending := range m.Images.Pending {
		label := imageLabel(pending.ID)
		start := indexRunes(valueRunes, label, 0)
		if start < 0 {
			continue
		}
		matches = append(matches, ImageTokenMatch{
			PendingIdx: idx,
			ID:         pending.ID,
			Label:      label,
			Start:      start,
			End:        start + len([]rune(label)),
		})
	}

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

// SelectedImageMatch returns the selected inline image token, if any.
func (m *Model) SelectedImageMatch() (ImageTokenMatch, bool) {
	if !m.Images.Selection.Active {
		return ImageTokenMatch{}, false
	}
	for _, match := range m.PendingImageMatches() {
		if match.PendingIdx == m.Images.Selection.PendingIdx {
			return match, true
		}
	}
	return ImageTokenMatch{}, false
}

// MatchAdjacentToCursor returns the inline image token adjacent to the cursor.
func (m *Model) MatchAdjacentToCursor(cursor int, wantStart bool) (ImageTokenMatch, bool) {
	for _, match := range m.PendingImageMatches() {
		if wantStart && cursor == match.Start {
			return match, true
		}
		if !wantStart && cursor == match.End {
			return match, true
		}
	}
	return ImageTokenMatch{}, false
}

// RemoveImageToken removes the inline token from the textarea and pending list.
func (m *Model) RemoveImageToken(match ImageTokenMatch, cursor int) {
	valueRunes := []rune(m.Textarea.Value())
	nextValue := string(valueRunes[:match.Start]) + string(valueRunes[match.End:])
	m.Textarea.SetValue(nextValue)
	m.Images.RemoveAt(match.PendingIdx)
	m.Images.Selection = ImageSelection{}
	m.SetCursorIndex(cursor)
	m.UpdateHeight()
}

// ExtractInlineImages removes inline image tokens from content and returns the
// ordered images based on their appearance in the text.
func (m *Model) ExtractInlineImages(input string) (string, []core.Image) {
	matches := m.PendingImageMatchesIn(input)
	if len(matches) == 0 {
		return strings.TrimSpace(input), nil
	}

	var images []core.Image
	valueRunes := []rune(input)
	var sb strings.Builder
	last := 0
	for _, match := range matches {
		if match.Start > len(valueRunes) || match.End > len(valueRunes) || match.PendingIdx >= len(m.Images.Pending) {
			continue
		}
		sb.WriteString(string(valueRunes[last:match.Start]))
		images = append(images, m.Images.Pending[match.PendingIdx].Data)
		last = match.End
	}
	sb.WriteString(string(valueRunes[last:]))
	return strings.TrimSpace(sb.String()), images
}

func (m *Model) stepCursor(keyType tea.KeyType) {
	var cmd tea.Cmd
	m.Textarea, cmd = m.Textarea.Update(tea.KeyMsg{Type: keyType})
	_ = cmd
}

func indexRunes(haystack []rune, needle string, start int) int {
	s := string(haystack)
	if start > 0 {
		// Convert rune offset to byte offset
		byteStart := len(string(haystack[:start]))
		idx := strings.Index(s[byteStart:], needle)
		if idx < 0 {
			return -1
		}
		// Convert byte position back to rune offset
		return start + len([]rune(s[byteStart:byteStart+idx]))
	}
	idx := strings.Index(s, needle)
	if idx < 0 {
		return -1
	}
	return len([]rune(s[:idx]))
}

// HistoryUp navigates to the previous history entry.
func (m *Model) HistoryUp() {
	if len(m.History.Items) == 0 {
		return
	}
	if m.History.Index == -1 {
		m.History.Stashed = m.Textarea.Value()
		m.History.Index = len(m.History.Items) - 1
	} else if m.History.Index > 0 {
		m.History.Index--
	}
	m.Textarea.SetValue(m.History.Items[m.History.Index])
	m.Textarea.CursorEnd()
	m.UpdateHeight()
}

// HistoryDown navigates to the next history entry.
func (m *Model) HistoryDown() {
	if m.History.Index == -1 {
		return
	}
	if m.History.Index < len(m.History.Items)-1 {
		m.History.Index++
		m.Textarea.SetValue(m.History.Items[m.History.Index])
	} else {
		m.History.Index = -1
		m.Textarea.SetValue(m.History.Stashed)
	}
	m.Textarea.CursorEnd()
	m.UpdateHeight()
}

// ProcessImageRefs extracts @image.png references from input.
// Returns the cleaned text content and any loaded images.
// Only processes references where the file actually exists on disk;
// non-existent file references are left in the text as-is.
func ProcessImageRefs(cwd, input string) (string, []core.Image, error) {
	matches := imageRefPattern.FindAllStringSubmatch(input, -1)
	if len(matches) == 0 {
		return input, nil, nil
	}

	var images []core.Image
	var loadedRefs []string // track which @references were successfully loaded
	for _, match := range matches {
		path := match[1]
		absPath := path
		if !filepath.IsAbs(absPath) {
			absPath = filepath.Join(cwd, absPath)
		}

		// Skip references to files that don't exist
		if _, err := os.Stat(absPath); os.IsNotExist(err) {
			continue
		}

		imgInfo, err := image.Load(absPath)
		if err != nil {
			return "", nil, fmt.Errorf("loading image %s: %w", absPath, err)
		}
		images = append(images, imgInfo.ToProviderData())
		loadedRefs = append(loadedRefs, match[0]) // full match including @
	}

	// Only remove references that were successfully loaded
	content := input
	for _, ref := range loadedRefs {
		content = strings.ReplaceAll(content, ref, "")
	}
	content = strings.TrimSpace(content)

	return content, images, nil
}

// PastePlaceholder returns the placeholder text displayed in the textarea for a pasted chunk.
func PastePlaceholder(index, lineCount int) string {
	return fmt.Sprintf("[Pasted text #%d +%d lines]", index, lineCount)
}

// FullValue returns the textarea value with paste placeholders expanded to the original pasted text.
func (m *Model) FullValue() string {
	val := m.Textarea.Value()
	for i, chunk := range m.PastedChunks {
		placeholder := PastePlaceholder(i+1, chunk.LineCount)
		val = strings.Replace(val, placeholder, chunk.Text, 1)
	}
	return val
}

// ClearPaste resets the pasted chunks state.
func (m *Model) ClearPaste() {
	m.PastedChunks = nil
}

// MinTextareaHeight returns the minimum textarea height constant.
func MinTextareaHeight() int {
	return minTextareaHeight
}

func (m *Model) Reset() {
	m.Textarea.Reset()
	m.Textarea.SetHeight(minTextareaHeight)
	m.ClearPaste()
	m.ClearImages()
	m.Queue.ResetSelection()
}

func (m *Model) HandleCwdChange(newCwd string) {
	m.Suggestions.SetCwd(newCwd)
	if m.Suggestions.GetSuggestionType() == suggest.TypeFile {
		m.Suggestions.Hide()
	}
}

func (m *Model) RecordSubmission(cwd, input string) {
	if input == "" {
		return
	}
	m.History.Items = append(m.History.Items, input)
	m.History.Index = -1
	m.History.Stashed = ""
	history.Save(cwd, m.History.Items)
}

func (m *Model) RestoreImages(images []core.Image) {
	m.Images.Pending = nil
	m.Images.Selection = ImageSelection{}
	for i, img := range images {
		id := m.Images.NextID + i + 1
		m.Images.Pending = append(m.Images.Pending, PendingImage{ID: id, Data: img})
	}
	m.Images.NextID += len(images)
}

func (m *Model) HasContent() bool {
	return strings.TrimSpace(m.Textarea.Value()) != "" || len(m.Images.Pending) > 0
}

func (m *Model) PendingImages() []core.Image {
	images := make([]core.Image, len(m.Images.Pending))
	for i, p := range m.Images.Pending {
		images[i] = p.Data
	}
	return images
}

// HandleTextareaUpdate forwards a message to the textarea and applies user-input
// side effects such as paste placeholder expansion, height updates, and suggestions.
// It returns the resulting tea.Cmd and whether the textarea value changed.
func (m *Model) HandleTextareaUpdate(msg tea.Msg) (tea.Cmd, bool) {
	var cmds []tea.Cmd
	var cmd tea.Cmd

	isPaste := false
	if keyMsg, ok := msg.(tea.KeyMsg); ok {
		isPaste = keyMsg.Paste
	}

	prevValue := m.Textarea.Value()
	m.Textarea, cmd = m.Textarea.Update(msg)
	cmds = append(cmds, cmd)

	if isPaste {
		newValue := m.Textarea.Value()
		pastedText := ExtractPastedText(prevValue, newValue)
		lines := strings.Split(pastedText, "\n")
		if len(lines) > 1 {
			chunk := PastedChunk{
				Text:      pastedText,
				LineCount: len(lines),
			}
			m.PastedChunks = append(m.PastedChunks, chunk)
			placeholder := PastePlaceholder(len(m.PastedChunks), chunk.LineCount)
			m.Textarea.SetValue(prevValue)
			m.Textarea.CursorEnd()
			m.Textarea.InsertString(placeholder)
		} else {
			trimmed := strings.TrimSpace(newValue)
			if trimmed != newValue {
				m.Textarea.SetValue(trimmed)
				m.Textarea.CursorEnd()
			}
		}
	}

	changed := m.Textarea.Value() != prevValue
	if changed {
		m.UpdateHeight()
		m.Suggestions.UpdateSuggestions(m.Textarea.Value())
	}

	return tea.Batch(cmds...), changed
}

// ExtractPastedText derives the pasted content by comparing the textarea
// value before and after the paste event.
func ExtractPastedText(prevValue, newValue string) string {
	if strings.HasPrefix(newValue, prevValue) {
		return strings.TrimSpace(newValue[len(prevValue):])
	}
	return strings.TrimSpace(newValue)
}

// HandleSuggestionKey handles keys while the autocomplete suggestion list is visible.
// Returns (cmd, true) if the key was consumed, (nil, false) otherwise.
func (m *Model) HandleSuggestionKey(msg tea.KeyMsg) (tea.Cmd, bool) {
	if !m.Suggestions.IsVisible() {
		return nil, false
	}
	switch msg.Type {
	case tea.KeyUp, tea.KeyCtrlP:
		m.Suggestions.MoveUp()
		return nil, true
	case tea.KeyDown, tea.KeyCtrlN:
		m.Suggestions.MoveDown()
		return nil, true
	case tea.KeyPgUp:
		m.Suggestions.MovePageUp()
		return nil, true
	case tea.KeyPgDown:
		m.Suggestions.MovePageDown()
		return nil, true
	case tea.KeyHome:
		m.Suggestions.MoveToTop()
		return nil, true
	case tea.KeyEnd:
		m.Suggestions.MoveToEnd()
		return nil, true
	case tea.KeyTab, tea.KeyEnter:
		if selected := m.Suggestions.GetSelected(); selected != "" {
			if m.Suggestions.GetSuggestionType() == suggest.TypeFile {
				currentValue := m.Textarea.Value()
				if atIdx := strings.LastIndex(currentValue, "@"); atIdx >= 0 {
					newValue := currentValue[:atIdx] + "@" + selected
					m.Textarea.SetValue(newValue)
					m.Textarea.CursorEnd()
				}
			} else {
				m.Textarea.SetValue(selected + " ")
				m.Textarea.CursorEnd()
			}
			m.Suggestions.Hide()
		}
		return nil, true
	case tea.KeyEsc:
		m.Suggestions.Hide()
		return nil, true
	}
	return nil, false
}
