package notifications

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"sync"
	"time"

	"github.com/rs/zerolog"
)

// Dispatcher fans a Notification out to every configured channel. Configured
// channels live in the per-rule `channels:` YAML list. The dispatcher always
// also writes to the dashboard channel (notification_log), regardless of the
// rule's explicit channels — operators always have a record.
type Dispatcher struct {
	db       *sql.DB
	channels map[string]Channel
	logger   zerolog.Logger
	mu       sync.RWMutex
}

// NewDispatcher constructs the dispatcher. Channels are registered with
// Register() after construction; the dashboard channel is always present
// (created internally) and writes to notification_log.
func NewDispatcher(db *sql.DB, logger zerolog.Logger) *Dispatcher {
	d := &Dispatcher{
		db:       db,
		channels: map[string]Channel{},
		logger:   logger.With().Str("component", "notifications").Logger(),
	}
	// Dashboard channel is built in; the operator can't disable the audit
	// trail, only suppress alerts on other channels.
	d.channels["dashboard"] = &dashboardChannel{db: db}
	return d
}

// Register adds a channel implementation under its Name(). Idempotent: a
// second Register for the same name replaces the first.
func (d *Dispatcher) Register(c Channel) {
	d.mu.Lock()
	defer d.mu.Unlock()
	d.channels[c.Name()] = c
}

// Dispatch fans n out to every channel listed in channelNames, plus the
// dashboard channel for the audit trail. perChannelConfig is keyed by
// channel name; missing keys mean "send with no extra config".
func (d *Dispatcher) Dispatch(ctx context.Context, n Notification, channelNames []string, perChannelConfig map[string]map[string]any) {
	if n.Timestamp.IsZero() {
		n.Timestamp = time.Now()
	}
	wanted := map[string]struct{}{"dashboard": {}}
	for _, c := range channelNames {
		wanted[c] = struct{}{}
	}

	d.mu.RLock()
	channels := make([]Channel, 0, len(wanted))
	for name := range wanted {
		if c, ok := d.channels[name]; ok {
			channels = append(channels, c)
		}
	}
	d.mu.RUnlock()

	for _, c := range channels {
		cfg := perChannelConfig[c.Name()]
		delivered, err := c.Send(ctx, n, cfg)
		entry := d.logger.Info()
		if err != nil {
			entry = d.logger.Warn().Err(err)
		}
		entry.
			Str("channel", c.Name()).
			Int("delivered", delivered).
			Str("title", n.Title).
			Msg("notification dispatched")
		// notification_log row per channel.
		d.recordLog(ctx, n, c.Name(), delivered > 0, err)
	}
}

// recordLog persists one notification_log row. Best-effort.
func (d *Dispatcher) recordLog(ctx context.Context, n Notification, channel string, delivered bool, errMsg error) {
	if d.db == nil {
		return
	}
	var ruleID sql.NullInt64
	if n.RuleID != 0 {
		ruleID = sql.NullInt64{Int64: n.RuleID, Valid: true}
	}
	var em sql.NullString
	if errMsg != nil {
		em = sql.NullString{String: errMsg.Error(), Valid: true}
	}
	_, _ = d.db.ExecContext(ctx, `
		INSERT INTO notification_log(rule_id, title, body, sent_at, delivered, channel, error_message)
		VALUES (?, ?, ?, ?, ?, ?, ?)
	`, ruleID, n.Title, nullString(n.Body), time.Now().Unix(),
		boolInt(delivered), channel, em)
}

// dashboardChannel is the built-in always-on channel. It does no external
// I/O — the notification_log row written by the dispatcher IS the dashboard
// delivery. This channel exists so the dispatcher's fan-out path treats
// "the audit log" as just another channel, keeping a uniform Channel API.
type dashboardChannel struct {
	db *sql.DB
}

func (c *dashboardChannel) Name() string { return "dashboard" }
func (c *dashboardChannel) Send(_ context.Context, _ Notification, _ map[string]any) (int, error) {
	return 1, nil
}

// LoadRecent returns the most recent notification_log rows for the
// /api/notifications/log endpoint.
func (d *Dispatcher) LoadRecent(ctx context.Context, limit int) ([]LogEntry, error) {
	if limit <= 0 || limit > 1000 {
		limit = 200
	}
	rows, err := d.db.QueryContext(ctx, `
		SELECT id, rule_id, title, body, sent_at, delivered, channel, error_message
		FROM notification_log ORDER BY sent_at DESC LIMIT ?
	`, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []LogEntry{}
	for rows.Next() {
		var (
			e         LogEntry
			ruleID    sql.NullInt64
			body      sql.NullString
			delivered int
			channel   sql.NullString
			errMsg    sql.NullString
		)
		if err := rows.Scan(&e.ID, &ruleID, &e.Title, &body, &e.SentAt,
			&delivered, &channel, &errMsg); err != nil {
			return nil, err
		}
		if ruleID.Valid {
			v := ruleID.Int64
			e.RuleID = &v
		}
		if body.Valid {
			e.Body = body.String
		}
		e.Delivered = delivered != 0
		if channel.Valid {
			e.Channel = channel.String
		}
		if errMsg.Valid {
			e.ErrorMessage = errMsg.String
		}
		out = append(out, e)
	}
	return out, rows.Err()
}

// LogEntry is the JSON shape returned to /api/notifications/log.
type LogEntry struct {
	ID           int64  `json:"id"`
	RuleID       *int64 `json:"rule_id,omitempty"`
	Title        string `json:"title"`
	Body         string `json:"body,omitempty"`
	SentAt       int64  `json:"sent_at"`
	Delivered    bool   `json:"delivered"`
	Channel      string `json:"channel,omitempty"`
	ErrorMessage string `json:"error_message,omitempty"`
}

// MarshalJSON ensures stable field ordering across Go versions (the standard
// reflective marshaling has been stable for years; this is here as a safety
// net should the test fixture become picky).
func (e LogEntry) MarshalJSON() ([]byte, error) {
	type alias LogEntry
	return json.Marshal(alias(e))
}

// utility shims duplicated from internal/db to keep this package self-contained.
func nullString(s string) any {
	if s == "" {
		return nil
	}
	return s
}
func boolInt(b bool) int {
	if b {
		return 1
	}
	return 0
}

// Sentinel: signal to a Channel that delivery succeeded with zero recipients
// (e.g. push channel with no active subscriptions) so the dispatcher
// surfaces it as "no error, no delivery" rather than misleading the log.
var ErrNoRecipients = errors.New("no recipients")
