package app

import (
	"context"
	"os"
	"path/filepath"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"chirami/internal/analyze"
	"chirami/internal/card"
	"chirami/internal/dict"
	"chirami/internal/morph"
	"chirami/internal/morph/mkmorph"
	"chirami/internal/platform"
)

type fakeBackend struct {
	sel    chan string
	events chan platform.Event
	shots  chan []byte
	asked  atomic.Bool // Screenshots was called

	mu     sync.Mutex
	shows  []platform.Popup
	hides  int
	px, py float64
}

func newFake() *fakeBackend {
	return &fakeBackend{sel: make(chan string, 8), events: make(chan platform.Event, 8), shots: make(chan []byte, 1)}
}

func (f *fakeBackend) Screenshots() <-chan []byte {
	f.asked.Store(true)
	return f.shots
}

func (f *fakeBackend) Selections() <-chan string     { return f.sel }
func (f *fakeBackend) Events() <-chan platform.Event { return f.events }
func (f *fakeBackend) Locate() (platform.Anchor, error) {
	return platform.Anchor{X: 100, Y: 100, Screen: platform.Rect{W: 1920, H: 1080}, Scale: 1}, nil
}

func (f *fakeBackend) Pointer() (float64, float64, bool) {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.px, f.py, true
}

func (f *fakeBackend) Show(p platform.Popup) error {
	f.mu.Lock()
	f.shows = append(f.shows, p)
	f.mu.Unlock()
	return nil
}

func (f *fakeBackend) Hide() {
	f.mu.Lock()
	f.hides++
	f.mu.Unlock()
}

func (f *fakeBackend) counts() (int, int) {
	f.mu.Lock()
	defer f.mu.Unlock()
	return len(f.shows), f.hides
}

// waitFor polls cond for up to a second.
func waitFor(t *testing.T, what string, cond func() bool) {
	t.Helper()
	for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {
		if cond() {
			return
		}
	}
	t.Fatalf("timed out waiting for %s", what)
}

func start(t *testing.T, jisho func(context.Context, string) ([]dict.Entry, error)) (*fakeBackend, *App) {
	t.Helper()
	return startWith(t, Deps{Jisho: jisho})
}

func startWith(t *testing.T, d Deps) (*fakeBackend, *App) {
	t.Helper()
	return startFake(t, newFake(), DefaultConfig, d)
}

func startFake(t *testing.T, f *fakeBackend, cfg Config, d Deps) (*fakeBackend, *App) {
	t.Helper()
	fonts, err := card.LoadFonts()
	if err != nil {
		t.Skip(err)
	}
	cfg.Debounce = 10 * time.Millisecond
	d.Analyzer = &analyze.Analyzer{}
	d.Renderer = &card.Renderer{Fonts: fonts, Theme: card.Night}
	a := New(cfg, d)
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})
	go func() {
		a.Run(ctx, f, "")
		close(done)
	}()
	t.Cleanup(func() {
		cancel()
		<-done
	})
	return f, a
}

func TestShowAndHide(t *testing.T) {
	f, _ := start(t, nil)
	f.sel <- "ありがとう"
	waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 1 })

	// the same text again does not redraw
	f.sel <- "ありがとう"
	time.Sleep(40 * time.Millisecond)
	if s, _ := f.counts(); s != 1 {
		t.Fatalf("shows = %d after repeating the selection", s)
	}

	f.sel <- "hello"
	waitFor(t, "hide", func() bool { _, h := f.counts(); return h == 1 })
}

func TestDebounceKeepsLast(t *testing.T) {
	f, _ := start(t, nil)
	f.sel <- "あ"
	f.sel <- "あり"
	f.sel <- "ありがとう"
	waitFor(t, "card", func() bool { s, _ := f.counts(); return s >= 1 })
	time.Sleep(40 * time.Millisecond)
	if s, _ := f.counts(); s != 1 {
		t.Fatalf("shows = %d, want one card for the settled selection", s)
	}
}

func TestJishoNeverForKana(t *testing.T) {
	calls := make(chan string, 4)
	f, _ := start(t, func(_ context.Context, w string) ([]dict.Entry, error) {
		calls <- w
		return nil, nil
	})
	f.sel <- "ありがとう"
	waitFor(t, "card", func() bool { s, _ := f.counts(); return s >= 1 })
	f.sel <- "日本"
	select {
	case w := <-calls:
		if w != "日本" {
			t.Fatalf("jisho asked for %q", w)
		}
	case <-time.After(time.Second):
		t.Fatal("jisho not asked for a kanji word the offline dictionary lacks")
	}
	select {
	case w := <-calls:
		t.Fatalf("unexpected extra jisho call for %q", w)
	case <-time.After(50 * time.Millisecond):
	}
}

func TestPlace(t *testing.T) {
	screen := platform.Rect{X: -1920, Y: 0, W: 1920, H: 1080}
	onScreen := func(c platform.Rect) bool {
		return c.X >= screen.X && c.Y >= screen.Y && c.X+c.W <= screen.X+screen.W && c.Y+c.H <= screen.Y+screen.H
	}
	pointers := [][2]float64{{-1900, 10}, {-10, 1070}, {-960, 500}, {-10, 10}, {-1900, 1070}, {-960, 300}, {-960, 800}}
	for _, hgt := range []float64{120, 420, 700, 1200} {
		r := &card.Result{Hit: platform.Rect{X: 18, Y: 18, W: 316, H: hgt}}
		for _, p := range pointers {
			for _, above := range []bool{true, false} {
				x, y, _ := place(platform.Anchor{X: p[0], Y: p[1], Screen: screen, Scale: 1}, r, above)
				c := platform.Rect{X: x + r.Hit.X, Y: y + r.Hit.Y, W: r.Hit.W, H: hgt}
				if hgt < screen.H && !onScreen(c) {
					t.Errorf("h=%v pointer %v: card %v leaves the screen", hgt, p, c)
				}
				if hgt <= 420 && c.Contains(p[0], p[1]) {
					t.Errorf("h=%v pointer %v: card %v covers the pointer", hgt, p, c)
				}
			}
		}
	}
}

// Growing the card (expanding it, opening a kanji page) must keep it on
// screen, flipping to the other side of the pointer when needed.
func TestGrowStaysOnScreen(t *testing.T) {
	screen := platform.Rect{W: 1920, H: 1080}
	an := platform.Anchor{X: 900, Y: 200, Screen: screen, Scale: 1}
	small := &card.Result{Hit: platform.Rect{X: 18, Y: 18, W: 200, H: 90}}
	big := &card.Result{Hit: platform.Rect{X: 18, Y: 18, W: 316, H: 480}}
	_, _, above := place(an, small, true)
	if !above {
		t.Fatal("a small card near the top should still fit above")
	}
	_, y, above := place(an, big, above)
	if above || y+big.Hit.Y < an.Y {
		t.Fatalf("the grown card should flip below the pointer, got above=%v top=%v", above, y+big.Hit.Y)
	}
}

type idiomDict struct{}

func (idiomDict) Lookup(w string) []dict.Entry {
	if w == "猫に小判" {
		return []dict.Entry{{Kanji: []dict.Form{{Text: "猫に小判"}}, Senses: []dict.Sense{{Gloss: []string{"pearls before swine"}}}}}
	}
	return nil
}
func (d idiomDict) LookupReading(w, r string) []dict.Entry          { return d.Lookup(w) }
func (idiomDict) LongestPrefix(s string, n int) (int, []dict.Entry) { return 0, nil }
func (idiomDict) Kanji(r rune) (dict.Kanji, bool)                   { return dict.Kanji{}, false }
func (idiomDict) Furigana(w, r string) ([]dict.Ruby, bool)          { return nil, false }

// A selection the dictionary knows as a whole opens on its own entry, and
// its words stay clickable, with a way back to the whole.
func TestPhraseFocus(t *testing.T) {
	fonts, err := card.LoadFonts()
	if err != nil {
		t.Skip(err)
	}
	a := New(DefaultConfig, Deps{
		Analyzer: &analyze.Analyzer{Morph: tokenizer(t), Dict: idiomDict{}},
		Renderer: &card.Renderer{Fonts: fonts, Theme: card.Night},
	})
	f := newFake()
	a.b = f
	a.leaving, a.tipTimer = stopped(time.Hour), stopped(time.Hour)
	a.tick = time.NewTicker(time.Hour)
	ctx := context.Background()
	a.selected(ctx, "猫に小判")
	v := a.top().(*view)
	if v.focus != wholePhrase || len(v.words) != 3 || len(v.entries) != 1 {
		t.Fatalf("focus %d, %d words, %d entries", v.focus, len(v.words), len(v.entries))
	}
	a.click(ctx, card.Region{Action: card.Expand})
	a.click(ctx, card.Region{Action: card.FocusWord, Index: 0})
	if v.focus != 0 {
		t.Fatalf("clicking 猫 focused %d", v.focus)
	}
	var back bool
	for _, r := range a.res.Regions {
		back = back || r.Action == card.FocusPhrase
	}
	if !back {
		t.Fatal("no way back to the whole phrase")
	}
	a.click(ctx, card.Region{Action: card.FocusPhrase})
	if v.focus != wholePhrase || len(v.entries) != 1 {
		t.Fatalf("back to the phrase: focus %d, %d entries", v.focus, len(v.entries))
	}
}

var (
	morphOnce sync.Once
	morphDict *morph.Dict
	morphErr  error
)

// tokenizer builds morph.bin once for the package's tests.
func tokenizer(t *testing.T) *morph.Dict {
	morphOnce.Do(func() {
		dir, err := os.MkdirTemp("", "app-morph")
		if err != nil {
			morphErr = err
			return
		}
		path := filepath.Join(dir, "morph.bin")
		if morphErr = mkmorph.Build(path); morphErr == nil {
			morphDict, morphErr = morph.Open(path)
		}
	})
	if morphErr != nil {
		t.Fatal(morphErr)
	}
	return morphDict
}

// A screenshot is read by the OCR helper and its text shown as a card; with
// no OCR configured the backend is never asked for screenshots, so macOS
// never prompts for Input Monitoring.
func TestScreenshotOCR(t *testing.T) {
	f, _ := start(t, nil)
	f.sel <- "ねこ"
	waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 1 })
	if f.asked.Load() {
		t.Fatal("asked for screenshots without OCR")
	}

	var got atomic.Value
	f, _ = startWith(t, Deps{OCR: func(_ context.Context, png []byte) (string, error) {
		got.Store(string(png))
		return "ありがとう", nil
	}})
	waitFor(t, "screenshots asked for", f.asked.Load)
	f.shots <- []byte("png")
	waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 1 })
	if got.Load() != "png" {
		t.Fatalf("OCR got %v", got.Load())
	}
}

func TestClip(t *testing.T) {
	for _, c := range []struct {
		in   string
		n    int
		want string
	}{
		{"短い。", 10, "短い。"},
		{"一つ目の文。二つ目の文はとても長い", 10, "一つ目の文。"},
		{"句点のないとても長い文章です", 5, "句点のない"},
	} {
		if got := clip(c.in, c.n); got != c.want {
			t.Errorf("clip(%q, %d) = %q, want %q", c.in, c.n, got, c.want)
		}
	}
}

// A glance card closes when the pointer leaves it; an expanded one stays
// until Escape or a click outside (Dismiss).
func TestGlanceLeavesExpandedStays(t *testing.T) {
	f, _ := start(t, nil)
	f.sel <- "ありがとう"
	waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 1 })
	f.events <- platform.Event{Kind: platform.Motion, X: 30, Y: 30}
	f.events <- platform.Event{Kind: platform.Leave}
	f.events <- platform.Event{Kind: platform.Motion, X: 30, Y: 30}
	time.Sleep(500 * time.Millisecond)
	if _, h := f.counts(); h != 0 {
		t.Fatal("the glance card closed although the pointer came back within the grace period")
	}
	f.events <- platform.Event{Kind: platform.Leave}
	waitFor(t, "glance card closing", func() bool { _, h := f.counts(); return h == 1 })

	f.sel <- "こんにちは"
	waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 2 })
	f.events <- platform.Event{Kind: platform.Click, X: 40, Y: 40} // a glance card expands wherever clicked
	f.events <- platform.Event{Kind: platform.Leave}
	time.Sleep(500 * time.Millisecond)
	if _, h := f.counts(); h != 1 {
		t.Fatal("the expanded card closed when the pointer left it")
	}
	f.events <- platform.Event{Kind: platform.Dismiss}
	waitFor(t, "dismiss", func() bool { _, h := f.counts(); return h == 2 })
}

// A glance card the pointer never came to closes once the pointer is well
// away from it.
func TestGlancePointerAway(t *testing.T) {
	f, _ := start(t, nil)
	f.sel <- "ありがとう"
	waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 1 })
	f.mu.Lock()
	f.px, f.py = 1500, 900
	f.mu.Unlock()
	waitFor(t, "hide", func() bool { _, h := f.counts(); return h == 1 })
}

// The × closes an expanded card.
func TestCloseButton(t *testing.T) {
	fonts, err := card.LoadFonts()
	if err != nil {
		t.Skip(err)
	}
	a := New(DefaultConfig, Deps{Analyzer: &analyze.Analyzer{}, Renderer: &card.Renderer{Fonts: fonts, Theme: card.Night}})
	f := newFake()
	a.b = f
	a.leaving, a.tipTimer = stopped(time.Hour), stopped(time.Hour)
	a.tick = time.NewTicker(time.Hour)
	ctx := context.Background()
	a.selected(ctx, "ありがとう")
	if hasClose(a.res) {
		t.Fatal("a glance card has a ×")
	}
	a.click(ctx, card.Region{Action: card.Expand})
	if !hasClose(a.res) {
		t.Fatal("the expanded card has no ×")
	}
	a.click(ctx, card.Region{Action: card.Close})
	if a.shown() {
		t.Fatal("the × did not close the card")
	}
}

func hasClose(r *card.Result) bool {
	for _, reg := range r.Regions {
		if reg.Action == card.Close {
			return true
		}
	}
	return false
}

// A glance card's leave timer must not close the next card.
func TestLeaveTimerDoesNotCloseNextCard(t *testing.T) {
	f, _ := start(t, nil)
	f.sel <- "ありがとう"
	waitFor(t, "card", func() bool { s, _ := f.counts(); return s == 1 })
	f.events <- platform.Event{Kind: platform.Motion, X: 30, Y: 30}
	f.events <- platform.Event{Kind: platform.Leave}
	f.sel <- "こんにちは"
	waitFor(t, "second card", func() bool { s, _ := f.counts(); return s == 2 })
	time.Sleep(450 * time.Millisecond)
	if _, h := f.counts(); h != 0 {
		t.Fatal("the first card's leave timer closed the second card")
	}
}

// Show mode with no Japanese in the text ends at once instead of waiting
// for its timeout.
func TestShowNonJapaneseEndsAtOnce(t *testing.T) {
	fonts, err := card.LoadFonts()
	if err != nil {
		t.Skip(err)
	}
	a := New(DefaultConfig, Deps{Analyzer: &analyze.Analyzer{}, Renderer: &card.Renderer{Fonts: fonts, Theme: card.Night}})
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	a.OnHide = cancel
	start := time.Now()
	a.Run(ctx, newFake(), "hello")
	if time.Since(start) > time.Second {
		t.Fatal("show mode waited for its timeout on text with no Japanese")
	}
}

type particleDict struct{}

func (particleDict) Lookup(w string) []dict.Entry {
	switch w {
	case "とは":
		return []dict.Entry{{Kana: []dict.Form{{Text: "とは", Common: true}}, Senses: []dict.Sense{{POS: []string{"exp", "prt"}, Gloss: []string{"indicates word or phrase being defined"}}}}}
	case "と":
		return []dict.Entry{{Kana: []dict.Form{{Text: "と", Common: true}}, Senses: []dict.Sense{{POS: []string{"prt"}, Gloss: []string{"if; when"}}}}}
	case "は":
		return []dict.Entry{{Kana: []dict.Form{{Text: "は", Common: true}}, Senses: []dict.Sense{{POS: []string{"prt"}, Gloss: []string{"indicates sentence topic"}}}}}
	}
	return nil
}
func (d particleDict) LookupReading(w, r string) []dict.Entry          { return d.Lookup(w) }
func (particleDict) LongestPrefix(s string, n int) (int, []dict.Entry) { return 0, nil }
func (particleDict) Kanji(r rune) (dict.Kanji, bool)                   { return dict.Kanji{}, false }
func (particleDict) Furigana(w, r string) ([]dict.Ruby, bool)          { return nil, false }

func TestCompoundParticlePartsAndLatinRomaji(t *testing.T) {
	fonts, err := card.LoadFonts()
	if err != nil {
		t.Skip(err)
	}
	a := New(DefaultConfig, Deps{
		Analyzer: &analyze.Analyzer{Morph: tokenizer(t), Dict: particleDict{}},
		Renderer: &card.Renderer{Fonts: fonts, Theme: card.Night},
	})
	v := a.newView("ミックスリストとは、YouTube が作成した")
	v.expanded = true
	v.focus = 2 // とは
	a.lookup(v)
	wv := a.wordModel(v)
	if wv.Words[3].Romaji != "" {
		t.Fatalf("punctuation romaji = %q, want empty", wv.Words[3].Romaji)
	}
	if wv.Words[4].Romaji != "YouTube" || wv.Words[4].Lookup {
		t.Fatalf("YouTube word = %+v, want Romaji=YouTube Lookup=false", wv.Words[4])
	}
	parts := wv.Words[2].Parts
	if len(parts) != 2 || parts[0].Gloss != "if" || parts[1].Gloss != "topic" {
		t.Fatalf("とは parts = %+v, want [if, topic]", parts)
	}
	if len(wv.Entries) == 0 || wv.Entries[0].Head != "とは" || wv.Entries[0].Same {
		t.Fatalf("とは entry = %+v, want Head=とは Same=false", wv.Entries)
	}
}
