package webui

import (
	"strconv"

	"kjol/wasmruntime"
	"kjol/wasmruntime/vdom"
)

// Port of jsruntime/uikit/Toast.tsx.
//
// Two ways in, depending on who owns the queue:
//
//   - Toaster (below) is the one you want. It owns the list, generates IDs, runs the
//     auto-dismiss timers, and animates the countdown bar. It is the Go stand-in for
//     the TSX's useToast context.
//   - ToastProvider is the dumb half: it renders a list you hand it and calls you
//     back on dismiss. Use it only if you already own the queue — and remember that
//     NOTHING will remove a toast for you.

// ToastType selects a toast's accent color and leading icon.
type ToastType string

const (
	ToastSuccess ToastType = "success"
	ToastError   ToastType = "error"
	ToastWarning ToastType = "warning"
	ToastInfo    ToastType = "info"
	ToastGeneric ToastType = "generic"
)

// ToastPosition is where the container is anchored on screen.
type ToastPosition string

const (
	ToastTopRight     ToastPosition = "top-right"
	ToastTopLeft      ToastPosition = "top-left"
	ToastBottomRight  ToastPosition = "bottom-right"
	ToastBottomLeft   ToastPosition = "bottom-left"
	ToastTopCenter    ToastPosition = "top-center"
	ToastBottomCenter ToastPosition = "bottom-center"
)

// Toast is one notification. Duration is in ms (0 = no auto-dismiss and no
// progress bar). Dismissible shows the close button; ShowProgress shows the
// (static) progress bar when Duration > 0. Type defaults to ToastInfo.
type Toast struct {
	ID           string
	Message      string
	Type         ToastType
	Duration     int
	Dismissible  bool
	ShowProgress bool
}

var toastTypeIcons = map[ToastType]string{
	ToastSuccess: "circle-check",
	ToastError:   "circle-xmark",
	ToastWarning: "triangle-exclamation",
	ToastInfo:    "circle-info",
	ToastGeneric: "",
}

const toastContainerBase = "fixed z-[200] flex flex-col gap-2"

var toastContainerPositions = map[ToastPosition]string{
	ToastTopRight:     "top-4 right-4",
	ToastTopLeft:      "top-4 left-4",
	ToastBottomRight:  "bottom-4 right-4 flex-col-reverse",
	ToastBottomLeft:   "bottom-4 left-4 flex-col-reverse",
	ToastTopCenter:    "top-4 left-1/2 -translate-x-1/2",
	ToastBottomCenter: "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse",
}

const toastBase = "relative overflow-hidden rounded-default shadow-lg border border-line border-l-4 bg-surface min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out"

var toastTypeBorder = map[ToastType]string{
	ToastSuccess: "border-l-green-700",
	ToastError:   "border-l-red-700",
	ToastWarning: "border-l-yellow-500",
	ToastInfo:    "border-l-sky-800",
	ToastGeneric: "border-l-neutral-400",
}

var toastIconColor = map[ToastType]string{
	ToastSuccess: "text-green-600",
	ToastError:   "text-red-600 dark:text-red-400",
	ToastWarning: "text-yellow-600",
	ToastInfo:    "text-sky-700 dark:text-sky-400",
	ToastGeneric: "",
}

// ToastItem renders a single toast. onDismiss receives the toast's ID when the
// close button is pressed.
//
// progressRef, when non-nil, is attached to the progress bar so a Toaster can drive
// it down to zero over the toast's lifetime (see Toaster.Push). Pass nil for a
// static bar.
func ToastItem(t Toast, onDismiss func(string), progressRef *vdom.Ref) *vdom.VNode {
	typ := t.Type
	if typ == "" {
		typ = ToastInfo
	}
	icon := toastTypeIcons[typ]
	showProgress := t.ShowProgress && t.Duration > 0

	row := []vdom.Mod{vdom.Attr("class", "flex items-start gap-3 p-4")}
	if icon != "" {
		row = append(row, Icon(icon, 20, cx("shrink-0 mt-0.5", toastIconColor[typ])))
	}
	row = append(row, vdom.Div(vdom.Attr("class", "flex-1 text-sm text-ink"), vdom.Text(t.Message)))
	if t.Dismissible {
		dismiss := []vdom.Mod{
			vdom.Attr("class", "shrink-0 cursor-pointer text-ink-faint hover:text-ink-soft bg-transparent border-0 p-0 transition-colors"),
			vdom.Attr("aria-label", "Dismiss"),
		}
		if onDismiss != nil {
			id := t.ID
			dismiss = append(dismiss, vdom.On(vdom.EVENT_CLICK, func() { onDismiss(id) }))
		}
		dismiss = append(dismiss, Icon("xmark", 16, ""))
		row = append(row, vdom.Button(dismiss...))
	}

	mods := []vdom.Mod{
		vdom.Attr("class", cx(toastBase, toastTypeBorder[typ])),
		vdom.Attr("role", "alert"),
		vdom.Div(row...),
	}
	if showProgress {
		// The bar is declared at full width; a Toaster transitions it to 0 over the
		// toast's duration, imperatively (see Toaster.Push). Declaring the same style
		// string on every render is what stops the reconciler's attribute diff from
		// resetting it back to 100% mid-countdown.
		bar := []vdom.Mod{
			vdom.Attr("class", "h-full bg-surface-strong"),
			vdom.Attr("style", "width:100%"),
		}
		if progressRef != nil {
			bar = append(bar, vdom.WithRef(progressRef))
		}
		mods = append(mods, vdom.Div(vdom.Attr("class", "h-1 w-full bg-surface-raised"),
			vdom.Div(bar...),
		))
	}
	return vdom.Div(mods...)
}

// ToastProviderProps configures ToastProvider. Position defaults to
// ToastBottomRight; MaxToasts defaults to 5 (only the most recent are kept).
type ToastProviderProps struct {
	Position  ToastPosition
	MaxToasts int
	Toasts    []Toast
	OnDismiss func(string)
}

// ToastProvider renders its children followed by the fixed toast container. It
// replaces the TSX ToastContext.Provider; the wrapper uses display:contents so
// it introduces no layout box of its own.
func ToastProvider(p ToastProviderProps, children ...*vdom.VNode) *vdom.VNode {
	position := p.Position
	if position == "" {
		position = ToastBottomRight
	}
	max := p.MaxToasts
	if max <= 0 {
		max = 5
	}
	toasts := p.Toasts
	if len(toasts) > max {
		toasts = toasts[len(toasts)-max:]
	}

	container := []vdom.Mod{
		vdom.Attr("class", cx(toastContainerBase, toastContainerPositions[position])),
		vdom.Attr("aria-live", "polite"),
		vdom.Attr("aria-label", "Notifications"),
	}
	for _, t := range toasts {
		container = append(container, ToastItem(t, p.OnDismiss, nil))
	}

	mods := []vdom.Mod{vdom.Attr("class", "contents")}
	mods = kids(mods, children)
	mods = append(mods, vdom.Div(container...))
	return vdom.Div(mods...)
}

// ---- Toaster: the managed version ----

// DefaultToastDuration is how long a toast lives when it does not say. 5s is long
// enough to read a sentence and short enough not to nag.
const DefaultToastDuration = 5000

// ToastSticky, as a Toast.Duration, means "never auto-dismiss": the user has to
// close it. It is negative because Go cannot distinguish an unset 0 from a
// deliberate one, and an unset duration must mean the sensible default, not
// "forever" — a toast that silently piles up is how a notification area rots.
const ToastSticky = -1

// Toaster owns a queue of toasts and DISMISSES THEM ON A TIMER. ToastProvider on
// its own does not: it renders whatever list it is handed, so a caller using it
// directly has to run the clocks itself (and, in practice, forgets — the toasts
// then stack up forever).
//
// Create it once, alongside your signals, and render it once near the root:
//
//	toaster := webui.NewToaster(webui.ToasterOptions{Position: webui.ToastBottomRight})
//	…
//	toaster.Success("Saved.")          // auto-dismisses after 5s
//	toaster.Push(webui.Toast{Message: "Upload failed", Type: webui.ToastError,
//	                         Duration: webui.ToastSticky})  // stays until dismissed
//
//	return func() *vdom.VNode { return Div(page, toaster.Render()) }
type Toaster struct {
	opts   ToasterOptions
	toasts *vdom.Signal[[]Toast]

	// timers and bars are keyed by toast ID: the pending auto-dismiss, and the
	// progress bar it is counting down.
	timers map[string]int
	bars   map[string]*vdom.Ref
	seq    int
}

// ToasterOptions configures a Toaster.
type ToasterOptions struct {
	Position  ToastPosition
	MaxToasts int
	// DefaultDuration overrides DefaultToastDuration for toasts that do not set one.
	DefaultDuration int
	// NoProgress hides the countdown bar (which is otherwise shown on any toast that
	// auto-dismisses).
	NoProgress bool
}

// NewToaster creates the controller.
func NewToaster(o ToasterOptions) *Toaster {
	if o.DefaultDuration == 0 {
		o.DefaultDuration = DefaultToastDuration
	}
	return &Toaster{
		opts:   o,
		toasts: vdom.NewSignal([]Toast{}),
		timers: map[string]int{},
		bars:   map[string]*vdom.Ref{},
	}
}

// Push adds a toast and schedules its dismissal. An empty ID gets one. Returns the
// ID, so a caller can dismiss it early.
//
// Duration 0 means the default; ToastSticky means never. The countdown is a real
// browser timer, so on the server (where SetTimeout is a no-op) nothing is
// scheduled — which is correct: SSR has no one to show a toast to.
func (t *Toaster) Push(toast Toast) string {
	if toast.ID == "" {
		t.seq++
		toast.ID = "toast-" + strconv.Itoa(t.seq)
	}
	if toast.Type == "" {
		toast.Type = ToastInfo
	}
	if toast.Duration == 0 {
		toast.Duration = t.opts.DefaultDuration
	}
	toast.Dismissible = true
	toast.ShowProgress = !t.opts.NoProgress && toast.Duration > 0

	t.toasts.Set(append(t.toasts.Get(), toast))

	if toast.Duration > 0 {
		id := toast.ID
		t.timers[id] = wasmruntime.SetTimeout(toast.Duration, func() {
			delete(t.timers, id)
			t.Dismiss(id)
		})
		t.startCountdown(id, toast.Duration)
	}
	return toast.ID
}

// startCountdown drives the progress bar from full to empty over the toast's life.
//
// It runs imperatively, not through a signal: a signal write per animation frame
// would re-render the whole application for a 1px-high bar. The bar is declared at
// width:100%, so it paints full first; then a transition takes it to 0.
func (t *Toaster) startCountdown(id string, duration int) {
	if t.opts.NoProgress {
		return
	}
	ref := t.bar(id)
	wasmruntime.AfterRender(func() {
		if !ref.Mounted() {
			return
		}
		wasmruntime.SetStyle(ref, "transition", "width "+strconv.Itoa(duration)+"ms linear")
		// One frame later: the browser has to commit width:100% before it can animate
		// away from it. Setting both in the same frame just jumps to 0.
		wasmruntime.RAF(func() { wasmruntime.SetStyle(ref, "width", "0%") })
	})
}

func (t *Toaster) bar(id string) *vdom.Ref {
	r, ok := t.bars[id]
	if !ok {
		r = vdom.NewRef()
		t.bars[id] = r
	}
	return r
}

// Success / Error / Warning / Info push a toast of that type with the default
// duration — the common case.
func (t *Toaster) Success(msg string) string { return t.Push(Toast{Message: msg, Type: ToastSuccess}) }
func (t *Toaster) Error(msg string) string   { return t.Push(Toast{Message: msg, Type: ToastError}) }
func (t *Toaster) Warning(msg string) string { return t.Push(Toast{Message: msg, Type: ToastWarning}) }
func (t *Toaster) Info(msg string) string    { return t.Push(Toast{Message: msg, Type: ToastInfo}) }

// Dismiss removes a toast now, cancelling its pending auto-dismiss.
func (t *Toaster) Dismiss(id string) {
	if timer, ok := t.timers[id]; ok {
		wasmruntime.ClearTimeout(timer)
		delete(t.timers, id)
	}
	delete(t.bars, id)

	next := make([]Toast, 0, len(t.toasts.Get()))
	for _, toast := range t.toasts.Get() {
		if toast.ID != id {
			next = append(next, toast)
		}
	}
	t.toasts.Set(next)
}

// Clear removes every toast and cancels every pending timer.
func (t *Toaster) Clear() {
	for _, timer := range t.timers {
		wasmruntime.ClearTimeout(timer)
	}
	t.timers = map[string]int{}
	t.bars = map[string]*vdom.Ref{}
	t.toasts.Set(nil)
}

// Toasts is the current queue.
func (t *Toaster) Toasts() []Toast { return t.toasts.Get() }

// Render draws the toast container (and any children, unchanged — so it can wrap a
// subtree, as the TSX provider did).
func (t *Toaster) Render(children ...*vdom.VNode) *vdom.VNode {
	position := t.opts.Position
	if position == "" {
		position = ToastBottomRight
	}
	limit := t.opts.MaxToasts
	if limit <= 0 {
		limit = 5
	}
	toasts := t.toasts.Get()
	if len(toasts) > limit {
		toasts = toasts[len(toasts)-limit:]
	}

	container := []vdom.Mod{
		vdom.Attr("class", cx(toastContainerBase, toastContainerPositions[position])),
		vdom.Attr("aria-live", "polite"),
		vdom.Attr("aria-label", "Notifications"),
	}
	for _, toast := range toasts {
		container = append(container, ToastItem(toast, t.Dismiss, t.bar(toast.ID)))
	}

	mods := []vdom.Mod{vdom.Attr("class", "contents")}
	mods = kids(mods, children)
	mods = append(mods, vdom.Div(container...))
	return vdom.Div(mods...)
}
