package app

import (
	"fmt"
	"slices"
	"strings"
	"unicode"

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

const (
	maxEntries  = 3
	maxMeanings = 3
	maxKanji    = 6
	kanjiWords  = 6
	maxSimilar  = 6
)

// page is what the card shows: a word view, or a kanji page reached from one.
type page interface{ title() string }

// view is everything the card shows for one selection.
type view struct {
	text     string
	words    []analyze.Word
	phrase   *analyze.Word // the whole selection, when the dictionary knows it as one
	focus    int           // index into words, or wholePhrase
	entries  []dict.Entry
	source   string // "jmdict", "jisho", or "" for none
	status   string
	expanded bool

	// what the card shows, so clicks can find what they point at
	shown []card.Entry
	kanji []rune
}

func (v *view) title() string { return v.text }

// wholePhrase as a view's focus means the entries describe the selection
// as a whole.
const wholePhrase = -2

// focused is the word the entries describe.
func (v *view) focused() (analyze.Word, bool) {
	switch {
	case v.focus == wholePhrase:
		return *v.phrase, true
	case v.focus >= 0:
		return v.words[v.focus], true
	}
	return analyze.Word{}, false
}

type kanjiPage struct {
	r       rune
	parts   []rune
	words   []dict.Entry
	similar []rune
	of      rune // the part the similar kanji share
}

func (k *kanjiPage) title() string { return string(k.r) }

func (a *App) newView(text string) *view {
	v := &view{text: text, words: a.an.Words(text), focus: -1}
	if p, ok := a.an.Phrase(text, v.words); ok {
		v.phrase, v.focus = &p, wholePhrase
		a.lookup(v)
		return v
	}
	for i, w := range v.words {
		if w.Content {
			v.focus = i
			break
		}
	}
	if v.focus < 0 && len(v.words) > 0 && !v.words[0].Symbol {
		v.focus = 0
	}
	a.lookup(v)
	return v
}

// lookup fills the offline entries for the focused word; it never touches
// the network.
func (a *App) lookup(v *view) {
	v.entries, v.source, v.status = nil, "", ""
	w, ok := v.focused()
	if !ok {
		return
	}
	if es := a.an.Entries(w); len(es) > 0 {
		v.entries, v.source = es, "jmdict"
		return
	}
	v.status = "(・・?) not in the offline dictionary"
}

// wantsJisho reports whether the focused word should be looked up online:
// only when the offline dictionary has nothing and there is kanji to look
// up. Kana never goes to the network.
func (a *App) wantsJisho(v *view) (string, bool) {
	w, ok := v.focused()
	if !a.cfg.Online || !ok || len(v.entries) > 0 {
		return "", false
	}
	if !strings.ContainsFunc(w.Surface, jp.IsKanji) {
		return "", false
	}
	return w.Key, w.Key != ""
}

func (a *App) newKanjiPage(r rune) *kanjiPage {
	k := &kanjiPage{r: r}
	if a.dict == nil {
		return k
	}
	k.parts = a.dict.Components(r)
	k.words = a.dict.WordsWith(r, kanjiWords)
	// the part shared with the fewest other kanji says the most
	best := 0
	for _, p := range k.parts {
		if p == r {
			continue
		}
		with := slices.DeleteFunc(a.dict.KanjiWith(p, maxSimilar+1), func(x rune) bool { return x == r })
		if len(with) >= 3 && (best == 0 || len(with) < best) {
			best, k.of, k.similar = len(with), p, with[:min(len(with), maxSimilar)]
		}
	}
	return k
}

func (a *App) model(p page) *card.Model {
	m := &card.Model{Hover: a.hover, Tip: a.tip, Zoom: a.cfg.zoom(a.scale)}
	if a.res != nil {
		m.Room = a.y + a.res.Hit.Y - a.anchor.Screen.Y - 6
	}
	switch p := p.(type) {
	case *view:
		m.Word = a.wordModel(p)
	case *kanjiPage:
		m.Kanji = a.kanjiModel(p)
	}
	return m
}

func (a *App) wordModel(v *view) *card.WordView {
	wv := &card.WordView{Focus: v.focus, Expanded: v.expanded, Status: v.status,
		Phrase: v.phrase != nil, WholePhrase: v.focus == wholePhrase}
	for i, w := range v.words {
		cw := card.Word{Segs: segs(w.Segs), Romaji: w.Romaji, Lookup: !w.Symbol}
		if a.cfg.NoRomaji || (w.Symbol && !hasAlnum(w.Romaji)) {
			cw.Romaji = "" // punctuation needs no romaji under it
		}
		if v.expanded && i == v.focus {
			cw.Parts = a.parts(w, v.entries)
		}
		wv.Words = append(wv.Words, cw)
	}
	v.shown, v.kanji = v.shown[:0], v.kanji[:0]
	w, ok := v.focused()
	if !ok {
		return wv
	}
	for i := range v.entries {
		if i == maxEntries {
			break
		}
		v.shown = append(v.shown, a.entry(&v.entries[i], w))
	}
	wv.Entries = v.shown
	for _, r := range w.Surface {
		if !jp.IsKanji(r) || slices.Contains(v.kanji, r) || len(v.kanji) == maxKanji {
			continue
		}
		v.kanji = append(v.kanji, r)
		chip := card.Chip{Text: string(r)}
		if k, ok := a.kanjiInfo(r); ok {
			chip.Tip = strings.Join(k.Meanings[:min(len(k.Meanings), 3)], ", ")
		}
		wv.Kanji = append(wv.Kanji, chip)
	}
	return wv
}

func (a *App) kanjiInfo(r rune) (dict.Kanji, bool) {
	if a.dict == nil {
		return dict.Kanji{}, false
	}
	return a.dict.Kanji(r)
}

func hasAlnum(s string) bool {
	return strings.ContainsFunc(s, func(r rune) bool {
		return unicode.IsLetter(r) || unicode.IsDigit(r)
	})
}

// parts splits a compound particle or inflected word for display: each
// particle or stem glossed with its meaning, each inflection with a short
// name and Yomitan's description on hover.
func (a *App) parts(w analyze.Word, entries []dict.Entry) []card.Part {
	if len(w.Parts) > 0 {
		var out []card.Part
		for _, p := range w.Parts {
			cp := card.Part{Segs: segs(p.Segs)}
			if !a.cfg.NoRomaji {
				cp.Romaji = p.Romaji
			}
			if es := a.an.Entries(p); len(es) > 0 {
				cp.Gloss = shortGloss(&es[0])
				cp.Tip = a.partTip(p, &es[0])
			}
			out = append(out, cp)
		}
		return out
	}
	pieces, ok := a.an.Inflection(w)
	if !ok {
		return nil
	}
	var out []card.Part
	for _, p := range pieces {
		cp := card.Part{Segs: segs(p.Segs)}
		if !a.cfg.NoRomaji {
			cp.Romaji = p.Romaji
		}
		if p.Name == "" {
			if len(entries) > 0 {
				cp.Gloss = shortGloss(&entries[0])
			}
		} else {
			cp.Gloss = stepLabel(p.Name)
			cp.Tip = p.Name + "\n" + describe(p.Description)
		}
		out = append(out, cp)
	}
	return out
}

// stepLabels shortens Yomitan's transform names to fit under a word part;
// the full name and its description are in the tip.
var stepLabels = map[string]string{
	"-た": "past", "-て": "te-form", "-いる": "ongoing", "-たい": "want to", "-ます": "polite",
	"-ば": "if", "-たら": "if/when", "-たり": "and so on", "-ず": "without", "-ちゃう": "end up",
	"-しまう": "end up", "-おく": "in advance", "-ある": "state", "-そう": "looks like",
	"potential or passive": "passive/potential", "imperative negative": "don't",
}

func stepLabel(name string) string {
	if l, ok := stepLabels[name]; ok {
		return l
	}
	return strings.TrimPrefix(name, "-")
}

// describe keeps the meaning lines of a Yomitan description, one per line,
// and drops its usage notes, which are about forming the word rather than
// reading it.
func describe(d string) string {
	var keep []string
	for _, ln := range strings.Split(d, "\n") {
		if strings.HasPrefix(ln, "Usage") || strings.HasPrefix(ln, "Refers to") {
			break
		}
		if ln = strings.TrimSpace(ln); ln != "" {
			keep = append(keep, ln)
		}
	}
	return strings.Join(keep, "\n")
}

func (a *App) partTip(w analyze.Word, e *dict.Entry) string {
	ce := a.entry(e, w)
	head := w.Surface
	if ce.Tip != "" {
		head += " · " + ce.Tip
	}
	var lines []string
	for i, m := range ce.Meanings {
		lines = append(lines, fmt.Sprintf("%d. %s", i+1, m))
	}
	if len(lines) == 0 {
		return head
	}
	return head + "\n" + strings.Join(lines, "\n")
}

// shortGloss is the first meaning cut to a label: "to eat" -> "eat",
// "indicates sentence topic" -> "topic".
func shortGloss(e *dict.Entry) string {
	if len(e.Senses) == 0 || len(e.Senses[0].Gloss) == 0 {
		return ""
	}
	g := strings.TrimPrefix(e.Senses[0].Gloss[0], "to ")
	for _, pfx := range []string{"indicates sentence ", "indicates the ", "indicates a ", "indicates "} {
		if after, ok := strings.CutPrefix(g, pfx); ok {
			g = after
			break
		}
	}
	for _, sfx := range []string{" of a sentence", " of action"} {
		g = strings.TrimSuffix(g, sfx)
	}
	if i := strings.IndexAny(g, "(;,"); i > 0 {
		g = g[:i]
	}
	return strings.TrimSpace(g)
}

func segs(ss []analyze.Seg) []card.Seg {
	out := make([]card.Seg, len(ss))
	for i, s := range ss {
		out[i] = card.Seg{Text: s.Text, Ruby: s.Ruby}
	}
	return out
}

// entry picks the forms of e that match the selected word and keeps the
// meanings that apply to them.
func (a *App) entry(e *dict.Entry, w analyze.Word) card.Entry {
	head := pickForm(e.Kanji, w.Surface, w.Key)
	reading := pickReading(e.Kana, head, w.KeyReading, w.Reading)
	if jp.IsKanaOnly(w.Surface) && usuallyKana(e) {
		head = "" // selected as kana and written that way: lead with the kana
	}
	ce := card.Entry{Head: head, Reading: reading, Downstep: -1, Common: e.Common(), JLPT: e.JLPT}
	if head == "" {
		ce.Head, ce.Reading = reading, ""
	}
	ce.DictForm = w.Key != "" && w.Key != w.Surface && (ce.Head == w.Key || ce.Reading == w.Key)
	ce.Same = ce.Head == w.Surface && len(w.Parts) == 0
	if !a.cfg.NoRomaji {
		ce.Romaji = a.an.Romaji(cmp(head, reading), reading)
	}
	for _, p := range e.Pitch {
		if jp.ToHiragana(p.Reading) == jp.ToHiragana(reading) && len(p.Downsteps) > 0 {
			ce.Downstep = p.Downsteps[0]
			break
		}
	}
	var tags []string
	for _, s := range e.Senses {
		if !applies(s.AppliesToKanji, head) || !applies(s.AppliesToKana, reading) {
			continue
		}
		if len(ce.Meanings) == 0 {
			for _, c := range slices.Concat(s.POS, s.Misc) {
				if l := a.label(c); !slices.Contains(tags, l) {
					tags = append(tags, l)
				}
			}
		}
		if len(ce.Meanings) < maxMeanings {
			ce.Meanings = append(ce.Meanings, strings.Join(s.Gloss, "; "))
		}
	}
	ce.Tip = strings.Join(tags, " · ")
	if alt := pickForm(e.Kanji); head == "" && alt != "" {
		ce.Tip = strings.TrimPrefix(ce.Tip+" · also written "+alt, " · ")
	}
	return ce
}

func (a *App) kanjiModel(k *kanjiPage) *card.KanjiView {
	kv := &card.KanjiView{Char: string(k.r)}
	if len(a.stack) > 1 {
		kv.Back = a.stack[len(a.stack)-2].title()
	}
	if info, ok := a.kanjiInfo(k.r); ok {
		kv.Meanings = strings.Join(info.Meanings, ", ")
		kv.On = strings.Join(info.On, " ")
		kv.Kun = strings.Join(info.Kun, " ")
		kv.Meta = kanjiMeta(info)
	}
	for _, p := range k.parts {
		if p != k.r {
			kv.Parts = append(kv.Parts, card.Chip{Text: string(p), Label: a.dict.ComponentName(p)})
		}
	}
	for _, e := range k.words {
		form := ""
		for _, f := range e.Kanji {
			if strings.ContainsRune(f.Text, k.r) && displayable(f) {
				form = f.Text
				break
			}
		}
		reading := ""
		if len(e.Kana) > 0 {
			reading = e.Kana[0].Text
		}
		kv.Words = append(kv.Words, card.Chip{Text: form, Label: shortGloss(&e), Tip: reading})
	}
	if k.of != 0 {
		kv.SimilarOf = string(k.of)
		for _, s := range k.similar {
			chip := card.Chip{Text: string(s)}
			if info, ok := a.kanjiInfo(s); ok && len(info.Meanings) > 0 {
				chip.Label = info.Meanings[0]
			}
			kv.Similar = append(kv.Similar, chip)
		}
	}
	return kv
}

func kanjiMeta(k dict.Kanji) string {
	meta := []string{fmt.Sprintf("%d strokes", k.Strokes)}
	switch {
	case k.Grade >= 1 && k.Grade <= 6:
		meta = append(meta, fmt.Sprintf("grade %d", k.Grade))
	case k.Grade == 8:
		meta = append(meta, "jōyō")
	}
	if k.Freq > 0 {
		meta = append(meta, fmt.Sprintf("#%d in news", k.Freq))
	}
	return strings.Join(meta, " · ")
}

// searchOnly marks forms JMdict keeps for lookup but that should not be shown
// as the headword.
var searchOnly = []string{"sK", "sk", "iK", "ik", "oK", "ok", "rK"}

func displayable(f dict.Form) bool {
	return !slices.ContainsFunc(f.Tags, func(t string) bool { return slices.Contains(searchOnly, t) })
}

// pickForm returns the kanji form matching one of want, else the first
// displayable form, else "".
func pickForm(forms []dict.Form, want ...string) string {
	for _, f := range forms {
		if slices.Contains(want, f.Text) {
			return f.Text
		}
	}
	for _, f := range forms {
		if displayable(f) {
			return f.Text
		}
	}
	return ""
}

func pickReading(kana []dict.Form, head string, want ...string) string {
	for _, f := range kana {
		if slices.Contains(want, jp.ToHiragana(f.Text)) {
			return f.Text
		}
	}
	for _, f := range kana {
		if displayable(f) {
			return f.Text
		}
	}
	if len(kana) > 0 {
		return kana[0].Text
	}
	return head
}

func usuallyKana(e *dict.Entry) bool {
	return len(e.Senses) > 0 && slices.Contains(e.Senses[0].Misc, "uk")
}

func applies(only []string, form string) bool {
	return len(only) == 0 || form == "" || slices.Contains(only, "*") || slices.Contains(only, form)
}
