package server

import (
	"context"
	"encoding/json"
	"log/slog"
	"strconv"
	"strings"
	"sync"

	"github.com/uopi/uopi/internal/access"
	"github.com/uopi/uopi/internal/audit"
	"github.com/uopi/uopi/internal/broker"
	"github.com/uopi/uopi/internal/controllogic"
	"github.com/uopi/uopi/internal/datasource"
)

// DialogHub fans control-logic dialog requests out to connected WebSocket
// clients whose identity matches the dialog's user/group filter, and routes
// input responses back to the dialog's target server variable.
//
// It implements controllogic.Notifier; the engine calls Notify when an
// action.dialog node runs. Input dialogs are remembered as pending so a later
// dialogResponse can be correlated by id and validated against its recipient
// filter — this is why panels can write the response target even though direct
// srv writes are otherwise gated to control-logic editors.
type DialogHub struct {
	broker *broker.Broker
	policy *access.Policy
	audit  audit.Recorder
	log    *slog.Logger

	mu      sync.Mutex
	clients map[*wsClient]struct{}
	pending map[string]controllogic.Dialog // input dialogs awaiting a response
}

// NewDialogHub builds an empty hub. rec is never nil after construction.
func NewDialogHub(brk *broker.Broker, policy *access.Policy, rec audit.Recorder, log *slog.Logger) *DialogHub {
	if rec == nil {
		rec = audit.Nop()
	}
	return &DialogHub{
		broker:  brk,
		policy:  policy,
		audit:   rec,
		log:     log,
		clients: map[*wsClient]struct{}{},
		pending: map[string]controllogic.Dialog{},
	}
}

func (h *DialogHub) add(c *wsClient) {
	h.mu.Lock()
	h.clients[c] = struct{}{}
	h.mu.Unlock()
}

func (h *DialogHub) remove(c *wsClient) {
	h.mu.Lock()
	delete(h.clients, c)
	h.mu.Unlock()
}

// dialogOut is the client-bound JSON form of a dialog request.
type dialogOut struct {
	Type    string `json:"type"` // always "dialog"
	ID      string `json:"id"`
	Kind    string `json:"kind"` // "info" | "error" | "input"
	Title   string `json:"title,omitempty"`
	Message string `json:"message,omitempty"`
}

// Notify implements controllogic.Notifier: serialise the dialog and push it to
// every connected client matching its user/group filter.
func (h *DialogHub) Notify(d controllogic.Dialog) {
	b, err := json.Marshal(dialogOut{
		Type:    "dialog",
		ID:      d.ID,
		Kind:    d.Kind,
		Title:   d.Title,
		Message: d.Message,
	})
	if err != nil {
		return
	}

	h.mu.Lock()
	if d.Kind == "input" && strings.TrimSpace(d.Target) != "" {
		h.pending[d.ID] = d
	}
	var targets []*wsClient
	for c := range h.clients {
		if h.matches(c.user, d) {
			targets = append(targets, c)
		}
	}
	h.mu.Unlock()

	for _, c := range targets {
		select {
		case c.outCh <- b:
		default:
		}
	}
}

// matches reports whether user is a recipient of d. An empty user+group filter
// targets everyone; otherwise the user must be named or in a named group.
func (h *DialogHub) matches(user string, d controllogic.Dialog) bool {
	if len(d.Users) == 0 && len(d.Groups) == 0 {
		return true
	}
	if h.policy != nil {
		user = h.policy.ResolveUser(user)
	}
	for _, u := range d.Users {
		if u == user {
			return true
		}
	}
	if len(d.Groups) > 0 && h.policy != nil {
		groups := map[string]bool{}
		for _, g := range h.policy.GroupsOf(user) {
			groups[g] = true
		}
		for _, g := range d.Groups {
			if groups[g] {
				return true
			}
		}
	}
	return false
}

// cancel drops a pending input dialog without writing (user dismissed it).
func (h *DialogHub) cancel(id string) {
	h.mu.Lock()
	delete(h.pending, id)
	h.mu.Unlock()
}

// respond writes an input dialog's response to its target server variable. The
// dialog must be pending and the responding client must have been a recipient;
// this gate replaces the usual srv write-permission check for sanctioned
// control-logic responses.
func (h *DialogHub) respond(ctx context.Context, c *wsClient, id string, value float64) {
	h.mu.Lock()
	d, ok := h.pending[id]
	if ok {
		delete(h.pending, id)
	}
	h.mu.Unlock()
	if !ok || !h.matches(c.user, d) {
		return
	}

	ds, name, ok := parseDialogTarget(d.Target)
	if !ok || ds == "local" {
		return
	}
	src, ok := h.broker.Source(ds)
	if !ok {
		h.log.Warn("dialog response: unknown data source", "ds", ds, "target", d.Target)
		return
	}

	ev := audit.Event{
		Actor:     c.user,
		ActorType: audit.ActorUser,
		Action:    "signal.write",
		DS:        ds,
		Signal:    name,
		Value:     strconv.FormatFloat(value, 'g', -1, 64),
		Detail:    "control logic dialog response",
		IP:        c.ip,
		Outcome:   audit.OutcomeOK,
	}
	wctx := datasource.WithUser(ctx, c.user)
	if err := src.Write(wctx, name, value); err != nil {
		h.log.Warn("dialog response: write failed", "ds", ds, "signal", name, "err", err)
		ev.Outcome = audit.OutcomeError
		ev.Error = err.Error()
	}
	h.audit.Record(ev)
}

// parseDialogTarget splits a "ds:name" dialog target on the first ':'. A bare
// name (no ':') defaults to the persistent server-variable source "srv".
func parseDialogTarget(t string) (ds, name string, ok bool) {
	t = strings.TrimSpace(t)
	if t == "" {
		return "", "", false
	}
	if i := strings.IndexByte(t, ':'); i >= 0 {
		return t[:i], t[i+1:], true
	}
	return "srv", t, true
}
