package dispatch

import (
	"context"
	"fmt"
	"sort"
	"strings"
	"sync"
	"time"
	"unicode"

	"github.com/rs/zerolog"
)

// DefaultPollInterval is the default interval between Telegram polls.
const DefaultPollInterval = 10 * time.Second

// DefaultPromptTemplate is the prompt sent to idle Claude agents.
// Must be a single line — tmux send-keys treats newlines as Enter keypresses.
const DefaultPromptTemplate = `The following task was sent via Telegram. Create an implementation ticket on Linear (HUM) for it, then execute the plan: %s`

// maxMessageTextLen caps the number of bytes of Telegram message text that
// reach the Claude prompt. Telegram allows messages up to 4096 characters,
// and 2000 is a round cap well above any realistic task description while
// below the point where tmux buffer behavior or prompt bloat become
// concerns. Text longer than this is truncated with a " [truncated]"
// marker so Claude sees that something was cut.
const maxMessageTextLen = 2000

// MessageSource fetches and acknowledges Telegram messages.
type MessageSource interface {
	FetchMessages(ctx context.Context) ([]QueuedMessage, error)
	AckMessage(ctx context.Context, updateID int) error
}

// AgentFinder discovers idle Claude tmux panes.
type AgentFinder interface {
	FindIdleAgents(ctx context.Context) ([]Agent, error)
}

// AgentSender sends a prompt to a tmux pane.
type AgentSender interface {
	SendPrompt(ctx context.Context, agent Agent, prompt string) error
}

// Notifier sends a notification back to the user.
type Notifier interface {
	Notify(ctx context.Context, chatID int64, text string) error
}

// QueuedMessage holds a Telegram message waiting for dispatch.
type QueuedMessage struct {
	UpdateID int
	ChatID   int64
	From     string
	Text     string
}

// Agent represents a Claude tmux pane that can receive work.
type Agent struct {
	SessionName string
	WindowIndex int
	PaneIndex   int
	Label       string // e.g. "session:0.1"
}

// Config holds dispatcher configuration.
type Config struct {
	PollInterval time.Duration
}

// Dispatcher polls Telegram and dispatches messages to idle Claude agents.
type Dispatcher struct {
	Source   MessageSource
	Finder   AgentFinder
	Sender   AgentSender
	Notifier Notifier
	Config   Config
	Logger   zerolog.Logger

	mu    sync.Mutex
	queue []QueuedMessage
	seen  map[int]bool
}

// Run starts the dispatch loop. It blocks until ctx is cancelled.
func (d *Dispatcher) Run(ctx context.Context) error {
	interval := d.Config.PollInterval
	if interval == 0 {
		interval = DefaultPollInterval
	}

	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	// Run once immediately on start.
	d.tick(ctx)

	for {
		select {
		case <-ctx.Done():
			return nil
		case <-ticker.C:
			d.tick(ctx)
		}
	}
}

func (d *Dispatcher) tick(ctx context.Context) {
	d.fetchMessages(ctx)
	if d.queueLen() == 0 {
		return
	}
	d.dispatchMessages(ctx)
}

// queueLen returns the current queue length under the lock.
func (d *Dispatcher) queueLen() int {
	d.mu.Lock()
	defer d.mu.Unlock()
	return len(d.queue)
}

func (d *Dispatcher) fetchMessages(ctx context.Context) {
	messages, err := d.Source.FetchMessages(ctx)
	if err != nil {
		d.Logger.Warn().Err(err).Msg("failed to fetch Telegram messages")
		return
	}

	d.mu.Lock()
	if d.seen == nil {
		d.seen = make(map[int]bool)
	}
	for _, msg := range messages {
		if d.seen[msg.UpdateID] {
			continue
		}
		d.seen[msg.UpdateID] = true
		d.queue = append(d.queue, msg)
		d.Logger.Info().Int("updateID", msg.UpdateID).Str("from", msg.From).Msg("queued message")
	}
	d.mu.Unlock()
}

// popQueue removes and returns the head of the queue under the lock.
// Returns ok=false if the queue is empty.
func (d *Dispatcher) popQueue() (QueuedMessage, bool) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if len(d.queue) == 0 {
		return QueuedMessage{}, false
	}
	msg := d.queue[0]
	d.queue = d.queue[1:]
	return msg, true
}

// requeueFront puts a message back at the head of the queue.
func (d *Dispatcher) requeueFront(msg QueuedMessage) {
	d.mu.Lock()
	defer d.mu.Unlock()
	d.queue = append([]QueuedMessage{msg}, d.queue...)
}

func (d *Dispatcher) dispatchMessages(ctx context.Context) {
	agents, err := d.Finder.FindIdleAgents(ctx)
	if err != nil {
		d.Logger.Warn().Err(err).Msg("failed to find idle agents")
		return
	}
	if len(agents) == 0 {
		d.Logger.Debug().Int("queued", d.queueLen()).Msg("no idle agents available")
		return
	}

	dispatched := 0
	for _, agent := range agents {
		msg, ok := d.popQueue()
		if !ok {
			break
		}
		prompt := buildPrompt(msg.Text)

		d.Logger.Info().Str("agent", agent.Label).Int("updateID", msg.UpdateID).Int("promptLen", len(prompt)).Msg("sending prompt via tmux send-keys")

		if err := d.Sender.SendPrompt(ctx, agent, prompt); err != nil {
			d.Logger.Warn().Err(err).Str("agent", agent.Label).Int("updateID", msg.UpdateID).Msg("send-keys failed, re-queuing")
			d.requeueFront(msg)
			continue
		}

		dispatched++

		d.Logger.Info().Str("agent", agent.Label).Int("updateID", msg.UpdateID).Str("from", msg.From).Msg("send-keys succeeded, dispatched message")

		if err := d.Source.AckMessage(ctx, msg.UpdateID); err != nil {
			d.Logger.Warn().Err(err).Int("updateID", msg.UpdateID).Msg("failed to ack message")
		}

		notifyText := fmt.Sprintf("Dispatched to %s", agent.Label)
		if err := d.Notifier.Notify(ctx, msg.ChatID, notifyText); err != nil {
			d.Logger.Warn().Err(err).Int64("chatID", msg.ChatID).Msg("failed to send dispatch notification")
		}
	}

	if dispatched > 0 {
		d.Logger.Info().Int("dispatched", dispatched).Int("remaining", d.queueLen()).Msg("dispatch cycle complete")
	}

	d.pruneSeen()
}

// maxSeenSize is the maximum number of update IDs to retain for deduplication.
// Telegram update IDs are monotonically increasing, so we evict the lowest
// IDs when the map exceeds this size.
const maxSeenSize = 1000

// pruneSeen evicts the oldest entries from the seen map when it exceeds maxSeenSize.
// Holds d.mu for the duration since seen is shared state.
func (d *Dispatcher) pruneSeen() {
	d.mu.Lock()
	defer d.mu.Unlock()
	if len(d.seen) <= maxSeenSize {
		return
	}
	ids := make([]int, 0, len(d.seen))
	for id := range d.seen {
		ids = append(ids, id)
	}
	sort.Ints(ids)
	// Keep only the most recent maxSeenSize/2 entries.
	keep := maxSeenSize / 2
	for _, id := range ids[:len(ids)-keep] {
		delete(d.seen, id)
	}
}

func buildPrompt(messageText string) string {
	// Strip control characters that could interfere with tmux/terminal.
	cleaned := strings.Map(func(r rune) rune {
		// unicode.IsControl is false for ' ' already, so the ' '
		// guard in the original version was dead. Keep only the
		// clauses that actually exclude printable whitespace we want.
		if unicode.IsControl(r) && r != '\t' {
			return -1
		}
		return r
	}, messageText)
	cleaned = strings.TrimSpace(cleaned)
	// Cap message length so oversized prompts cannot flood a Claude agent.
	// Byte-level truncation is sufficient here: tmux does not care about
	// rune boundaries, and the " [truncated]" marker lets Claude see that
	// the message was cut regardless of whether we sliced mid-rune.
	if len(cleaned) > maxMessageTextLen {
		cleaned = cleaned[:maxMessageTextLen] + " [truncated]"
	}
	return fmt.Sprintf(DefaultPromptTemplate, cleaned)
}
