// Package app wires the selection watcher, analysis, renderer and popup
// together and decides when the card comes and goes.
package app

import (
	"context"
	"log"
	"math"
	"net/url"
	"os"
	"slices"
	"strings"
	"time"
	"unicode/utf8"

	"chirami/internal/analyze"
	"chirami/internal/card"
	"chirami/internal/dict"
	"chirami/internal/jp"
	"chirami/internal/platform"
)

type Config struct {
	Online   bool          // allow jisho.org when the offline dictionary misses
	Debounce time.Duration // quiet time after the selection settles
	MaxRunes int           // longer selections are ignored
	Idle     time.Duration // hide a card nobody touched, where the pointer is invisible
	NoRomaji bool          // leave romaji out, for once kana reads by itself
	Zoom     float64       // card size on 1x displays, where small text is too few pixels to read
}

var DefaultConfig = Config{Online: true, Debounce: 250 * time.Millisecond, MaxRunes: 120, Idle: 15 * time.Second, Zoom: 1.25}

// zoom is how much bigger to draw the card on a display at scale: only
// low-density displays get the configured zoom.
func (c Config) zoom(scale float64) float64 {
	if scale < 1.5 && c.Zoom > 0 {
		return c.Zoom
	}
	return 1
}

const (
	tipDelay   = 200 * time.Millisecond // so passing over things does not flicker
	leaveGrace = 350 * time.Millisecond // so brushing past a glance card's edge does not close it
)

type Deps struct {
	Analyzer *analyze.Analyzer
	Dict     *dict.Dict // nil without dict.bin
	Renderer *card.Renderer
	Speak    func(string) error
	Open     func(string) error
	Jisho    func(ctx context.Context, word string) ([]dict.Entry, error)
	OCR      func(ctx context.Context, png []byte) (string, error) // nil: screenshots are ignored
}

type App struct {
	cfg Config
	Deps
	OnHide func() // called whenever the card goes away
	an     *analyze.Analyzer
	dict   *dict.Dict
	b      platform.Backend

	stack  []page // the selection's view, then pages opened from it; empty when hidden
	res    *card.Result
	anchor platform.Anchor
	x, y   float64 // image top-left
	above  bool    // the card sits above the pointer and grows upwards
	scale  float64 // device px per logical px of the card on screen

	// A glance card (not expanded) closes when the pointer leaves it, or,
	// if the pointer never came, once it moves away (where the backend can
	// tell) or after Config.Idle. An expanded card stays until its ×,
	// Escape, or a click outside (Dismiss, where the backend sees one).
	shownAt time.Time
	entered bool
	leaving *time.Timer

	hover    int  // region under the pointer, -1 for none
	tip      bool // the hovered region's tip is showing
	tipTimer *time.Timer
	tick     *time.Ticker

	jisho     chan jishoResult
	jishoMemo map[string][]dict.Entry
	ocr       chan ocrResult
}

type ocrResult struct {
	text string
	err  error
}

type jishoResult struct {
	v       *view
	key     string
	entries []dict.Entry
	err     error
}

func New(cfg Config, d Deps) *App {
	return &App{cfg: cfg, Deps: d, an: d.Analyzer, dict: d.Dict, hover: -1,
		jisho: make(chan jishoResult, 4), jishoMemo: map[string][]dict.Entry{}, ocr: make(chan ocrResult, 1)}
}

func stopped(d time.Duration) *time.Timer {
	t := time.NewTimer(d)
	t.Stop()
	return t
}

// Run handles selections until ctx ends. If initial is not empty it is shown
// right away, as if it had just been selected.
func (a *App) Run(ctx context.Context, b platform.Backend, initial string) error {
	a.b = b
	debounce := stopped(time.Hour)
	a.leaving, a.tipTimer = stopped(time.Hour), stopped(time.Hour)
	defer a.leaving.Stop()
	defer a.tipTimer.Stop()
	// the idle check only runs while a card is up, so an idle chirami sleeps
	a.tick = time.NewTicker(150 * time.Millisecond)
	a.tick.Stop()
	defer a.tick.Stop()
	selections := b.Selections()
	var shots <-chan []byte
	if s, ok := b.(platform.Screenshotter); ok && a.OCR != nil {
		shots = s.Screenshots()
	}
	pending := initial
	if initial != "" {
		// showing one text: the live selection is not ours to follow
		selections, shots = nil, nil
		debounce.Reset(0)
	}
	for {
		select {
		case <-ctx.Done():
			a.hide("done")
			return ctx.Err()
		case t := <-selections:
			pending = t
			debounce.Reset(a.cfg.Debounce)
		case <-debounce.C:
			a.selected(ctx, pending)
		case <-a.leaving.C:
			if !a.expanded() {
				a.hide("left")
			}
		case png := <-shots:
			go func() {
				text, err := a.OCR(ctx, png)
				select {
				case a.ocr <- ocrResult{text, err}:
				case <-ctx.Done():
				}
			}()
		case r := <-a.ocr:
			if r.err != nil {
				log.Printf("ocr: %v", r.err)
				break
			}
			debugf("ocr %q", r.text)
			a.open(ctx, clip(r.text, a.cfg.MaxRunes))
		case e := <-b.Events():
			a.event(ctx, e)
		case <-a.tipTimer.C:
			a.tip = true
			a.redraw()
		case r := <-a.jisho:
			a.jishoDone(r)
		case <-a.tick.C:
			a.idle()
		}
	}
}

func (a *App) shown() bool { return len(a.stack) > 0 }

func (a *App) top() page { return a.stack[len(a.stack)-1] }

func (a *App) selected(ctx context.Context, text string) {
	debugf("selection %q", text)
	a.open(ctx, text)
}

// open shows the card for text, selected or read from a screenshot.
func (a *App) open(ctx context.Context, text string) {
	text = strings.TrimSpace(text)
	if !jp.HasJapanese(text) || utf8.RuneCountInString(text) > a.cfg.MaxRunes {
		wasShown := a.shown()
		a.hide("not japanese")
		if !wasShown && a.OnHide != nil {
			a.OnHide() // nothing was shown, so nothing will ever hide
		}
		return
	}
	if a.shown() && a.stack[0].title() == text {
		return
	}
	anchor, err := a.b.Locate()
	if err != nil {
		log.Printf("locate: %v", err)
		return
	}
	v := a.newView(text)
	a.stack = []page{v}
	a.scale = anchor.Scale
	a.hover, a.tip = -1, false
	a.res = a.Renderer.Render(a.model(v), a.scale)
	a.anchor = anchor
	a.x, a.y, a.above = place(anchor, a.res, true)
	a.entered, a.shownAt = false, time.Now()
	a.leaving.Stop()
	a.tick.Reset(150 * time.Millisecond)
	a.show()
	a.maybeJisho(ctx, v)
}

// clip shortens OCR text to at most n runes, at the end of a sentence when
// there is one: a whole dialogue box should not become a giant card.
func clip(text string, n int) string {
	r := []rune(text)
	if len(r) <= n {
		return text
	}
	r = r[:n]
	for i := len(r) - 1; i > n/3; i-- {
		if strings.ContainsRune("。！？!?」", r[i]) {
			return string(r[:i+1])
		}
	}
	return string(r)
}

// place puts the card above the pointer, where it covers text already read
// rather than the line coming next, or below it when above is false or
// there is no room. The side nearest the pointer stays put as the card
// grows; when neither side has room the card is pushed back on screen,
// even over the pointer.
func place(an platform.Anchor, r *card.Result, above bool) (x, y float64, isAbove bool) {
	const dx, dy, edge = 10, 14, 6
	s, h := an.Screen, r.Hit
	top, bottom := s.Y+edge, s.Y+s.H-edge
	fitsAbove := an.Y-dy-h.H >= top
	fitsBelow := an.Y+dy+6+h.H <= bottom
	if above && !fitsAbove && fitsBelow || !above && !fitsBelow && fitsAbove {
		above = !above
	}
	cy := an.Y + dy + 6 // the card's top edge
	if above {
		cy = an.Y - dy - h.H
	}
	cy = math.Max(top, math.Min(cy, bottom-h.H))

	cx := an.X - 16
	if cx+h.W > s.X+s.W-edge {
		cx = an.X - dx - h.W
	}
	cx = math.Max(s.X+edge, math.Min(cx, s.X+s.W-edge-h.W))
	return cx - h.X, cy - h.Y, above
}

func (a *App) show() {
	err := a.b.Show(platform.Popup{Image: a.res.Image, X: a.x, Y: a.y, Hit: a.res.Hit})
	if err != nil {
		log.Printf("show: %v", err)
	}
}

// redraw renders the current page again and places it on the same side
// of the pointer, so the edge nearest the pointer does not move.
func (a *App) redraw() {
	if !a.shown() || a.res == nil {
		return
	}
	a.res = a.Renderer.Render(a.model(a.top()), a.scale)
	a.x, a.y, a.above = place(a.anchor, a.res, a.above)
	a.show()
}

// expanded reports whether the card is past its glance: clicked open, or
// on a kanji page.
func (a *App) expanded() bool {
	v, ok := a.top().(*view)
	return !ok || v.expanded
}

func (a *App) hide(why string) {
	if !a.shown() {
		return
	}
	debugf("hide: %s", why)
	a.stack, a.res = nil, nil
	a.tick.Stop()
	a.leaving.Stop()
	a.tipTimer.Stop()
	a.b.Hide()
	if a.OnHide != nil {
		a.OnHide()
	}
}

// Render draws the card for text without a backend, for previews.
func (a *App) Render(text string, expanded bool, scale float64) *card.Result {
	v := a.newView(text)
	v.expanded = expanded
	a.stack, a.scale = []page{v}, scale
	defer func() { a.stack = nil }()
	return a.Renderer.Render(a.model(v), scale)
}

func (a *App) regionAt(x, y float64) int {
	if a.res == nil {
		return -1
	}
	for i, r := range a.res.Regions {
		if r.Rect.Contains(x, y) {
			return i
		}
	}
	return -1
}

func (a *App) event(ctx context.Context, e platform.Event) {
	if !a.shown() {
		return
	}
	switch e.Kind {
	case platform.Motion:
		a.entered = true
		a.leaving.Stop()
		if h := a.regionAt(e.X, e.Y); h != a.hover {
			a.hover, a.tip = h, false
			a.tipTimer.Stop()
			if h >= 0 && a.res.Regions[h].Tip != "" {
				a.tipTimer.Reset(tipDelay)
			}
			a.redraw()
		}
	case platform.Leave:
		if !a.expanded() {
			a.leaving.Reset(leaveGrace)
		}
	case platform.Dismiss:
		a.hide("dismissed")
	case platform.Click:
		a.entered = true
		a.leaving.Stop()
		if i := a.regionAt(e.X, e.Y); i >= 0 {
			a.click(ctx, a.res.Regions[i])
		}
	}
}

func (a *App) click(ctx context.Context, r card.Region) {
	if r.Action == card.Close {
		a.hide("closed")
		return
	}
	switch p := a.top().(type) {
	case *view:
		a.clickView(ctx, p, r)
	case *kanjiPage:
		a.clickKanji(ctx, p, r)
	}
}

func (a *App) clickView(ctx context.Context, v *view, r card.Region) {
	switch r.Action {
	case card.Expand:
		v.expanded = true
		a.redraw()
	case card.SpeakAll:
		a.say(v.text)
	case card.SpeakEntry:
		if r.Index < len(v.shown) {
			e := v.shown[r.Index]
			a.say(cmp(e.Reading, e.Head))
		}
	case card.FocusWord, card.FocusPhrase:
		focus := r.Index
		if r.Action == card.FocusPhrase {
			focus = wholePhrase
		}
		if focus != v.focus {
			v.focus = focus
			a.lookup(v)
			a.redraw()
			a.maybeJisho(ctx, v)
		}
	case card.PickEntry:
		if r.Index < len(v.entries) {
			e := v.entries[r.Index]
			v.entries = slices.Insert(slices.Delete(v.entries, r.Index, r.Index+1), 0, e)
			a.redraw()
		}
	case card.OpenKanji:
		if r.Index < len(v.kanji) {
			a.push(a.newKanjiPage(v.kanji[r.Index]))
		}
	case card.OpenJisho:
		q := v.text
		if w, ok := v.focused(); ok {
			q = cmp(w.Key, w.Surface)
		}
		a.openJisho(q)
	}
}

func (a *App) clickKanji(ctx context.Context, k *kanjiPage, r card.Region) {
	switch r.Action {
	case card.Back:
		a.stack = a.stack[:len(a.stack)-1]
		a.hover, a.tip = -1, false
		a.redraw()
	case card.OpenPart:
		var parts []rune
		for _, p := range k.parts {
			if p != k.r {
				parts = append(parts, p)
			}
		}
		if r.Index < len(parts) {
			if _, ok := a.kanjiInfo(parts[r.Index]); ok {
				a.push(a.newKanjiPage(parts[r.Index]))
			}
		}
	case card.OpenSimilar:
		if r.Index < len(k.similar) {
			a.push(a.newKanjiPage(k.similar[r.Index]))
		}
	case card.OpenWord:
		if r.Index < len(k.words) {
			e := k.words[r.Index]
			text := pickForm(e.Kanji)
			for _, f := range e.Kanji {
				if strings.ContainsRune(f.Text, k.r) && displayable(f) {
					text = f.Text
					break
				}
			}
			v := a.newView(text)
			v.expanded = true
			a.push(v)
			a.maybeJisho(ctx, v)
		}
	}
}

// push opens a page on top of the current one; its back link returns.
func (a *App) push(p page) {
	a.stack = append(a.stack, p)
	a.hover, a.tip = -1, false
	a.tipTimer.Stop()
	a.redraw()
}

func (a *App) openJisho(q string) {
	if a.Open == nil {
		return
	}
	if err := a.Open("https://jisho.org/search/" + url.PathEscape(q)); err != nil {
		log.Printf("open: %v", err)
	}
}

func (a *App) say(s string) {
	if a.Speak == nil {
		return
	}
	if err := a.Speak(s); err != nil {
		log.Printf("speak: %v", err)
	}
}

// idle hides a card the user walked away from: the pointer went far from it
// without entering, or (where the pointer cannot be seen) it sat untouched.
// idle closes a glance card the pointer never came to: once the pointer
// is well away from it where the backend can tell, else after Config.Idle.
func (a *App) idle() {
	if !a.shown() || a.res == nil || a.entered || a.expanded() {
		return
	}
	x, y, ok := a.b.Pointer()
	if !ok {
		if time.Since(a.shownAt) > a.cfg.Idle {
			a.hide("idle")
		}
		return
	}
	h := a.res.Hit
	cx := math.Max(a.x+h.X, math.Min(x, a.x+h.X+h.W))
	cy := math.Max(a.y+h.Y, math.Min(y, a.y+h.Y+h.H))
	if math.Hypot(x-cx, y-cy) > 180 {
		a.hide("pointer went away")
	}
}

func (a *App) maybeJisho(ctx context.Context, v *view) {
	key, ok := a.wantsJisho(v)
	if !ok || a.Jisho == nil {
		return
	}
	if es, ok := a.jishoMemo[key]; ok {
		a.applyJisho(v, es, nil)
		a.redraw()
		return
	}
	v.status = "( ˘ω˘ ) asking jisho…"
	a.redraw()
	go func() {
		ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
		defer cancel()
		es, err := a.Jisho(ctx, key)
		a.jisho <- jishoResult{v, key, es, err}
	}()
}

func (a *App) jishoDone(r jishoResult) {
	if r.err == nil {
		a.jishoMemo[r.key] = r.entries
	}
	if w, ok := r.v.focused(); !slices.Contains(a.stack, page(r.v)) || !ok || w.Key != r.key {
		return // the card moved on
	}
	a.applyJisho(r.v, r.entries, r.err)
	if a.top() == page(r.v) {
		a.redraw()
	}
}

func (a *App) applyJisho(v *view, es []dict.Entry, err error) {
	switch {
	case err != nil:
		log.Printf("jisho: %v", err)
		v.status = "(｡•́︿•̀｡) couldn't reach jisho"
	case len(es) == 0:
		v.status = "(・・?) jisho doesn't know it either"
	default:
		v.entries, v.source, v.status = es, "jisho", ""
	}
}

var debug = os.Getenv("CHIRAMI_DEBUG") != ""

func debugf(format string, args ...any) {
	if debug {
		log.Printf(format, args...)
	}
}

func cmp(s, fallback string) string {
	if s != "" {
		return s
	}
	return fallback
}
