package rules

import (
	"context"
	"database/sql"
	"path/filepath"
	"testing"
	"testing/fstest"
	"time"

	"github.com/rs/zerolog"

	dbpkg "github.com/operator/command-center/internal/db"
	"github.com/operator/command-center/internal/eventbus"
	"github.com/operator/command-center/internal/notifications"
	"github.com/operator/command-center/internal/webhooks"
)

func openRulesDB(t *testing.T) *sql.DB {
	t.Helper()
	dbPath := filepath.Join(t.TempDir(), "test.db")
	db, err := dbpkg.OpenSQLite(dbPath)
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	migs, _ := dbpkg.LoadMigrations(fstest.MapFS{
		"002_phase1.sql": {Data: []byte(`
			CREATE TABLE trackers (id TEXT PRIMARY KEY, name TEXT, type TEXT, base_url TEXT, config_json TEXT, enabled INTEGER, scrape_interval_seconds INTEGER, scrape_jitter_seconds INTEGER, use_byparr INTEGER, created_at INTEGER, updated_at INTEGER);
			CREATE TABLE ratio_snapshots (id INTEGER PRIMARY KEY AUTOINCREMENT, tracker_id TEXT NOT NULL, timestamp INTEGER NOT NULL, simulation_id INTEGER, real_uploaded_bytes INTEGER, real_downloaded_bytes INTEGER, real_ratio REAL, displayed_uploaded_bytes INTEGER, displayed_downloaded_bytes INTEGER, displayed_ratio REAL, bonus_points INTEGER, unsat_count INTEGER, unsat_limit INTEGER, class_or_rank TEXT, raw_json TEXT);
		`)},
		"007_phase6.sql": {Data: []byte(`
			CREATE TABLE notification_rules (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, trigger_type TEXT NOT NULL, trigger_config_json TEXT NOT NULL, channels_json TEXT NOT NULL DEFAULT '["push"]', cooldown_seconds INTEGER NOT NULL DEFAULT 3600, last_fired_at INTEGER, source TEXT NOT NULL DEFAULT 'yaml', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
			CREATE TABLE notification_log (id INTEGER PRIMARY KEY AUTOINCREMENT, rule_id INTEGER, title TEXT NOT NULL, body TEXT, sent_at INTEGER NOT NULL, delivered INTEGER NOT NULL DEFAULT 0, channel TEXT, subscription_id INTEGER, error_message TEXT);
			CREATE TABLE push_subscriptions (id INTEGER PRIMARY KEY AUTOINCREMENT, endpoint TEXT UNIQUE NOT NULL, p256dh TEXT NOT NULL, auth TEXT NOT NULL, user_agent TEXT, device_label TEXT, created_at INTEGER NOT NULL, last_delivery_at INTEGER, failure_count INTEGER NOT NULL DEFAULT 0);
		`)},
	})
	if _, err := dbpkg.Apply(db, migs); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	return db
}

func insertRule(t *testing.T, db *sql.DB, name, triggerType, triggerConfigJSON, channelsJSON string, cooldown int) {
	t.Helper()
	_, err := db.Exec(`
		INSERT INTO notification_rules
		  (name, enabled, trigger_type, trigger_config_json, channels_json,
		   cooldown_seconds, source, created_at, updated_at)
		VALUES (?, 1, ?, ?, ?, ?, 'yaml', ?, ?)
	`, name, triggerType, triggerConfigJSON, channelsJSON, cooldown,
		time.Now().Unix(), time.Now().Unix())
	if err != nil {
		t.Fatalf("insertRule: %v", err)
	}
}

func TestEngineFiresOnAutobrrGrab(t *testing.T) {
	db := openRulesDB(t)
	bus := eventbus.New(zerolog.Nop())
	d := notifications.NewDispatcher(db, zerolog.Nop())
	e := NewEngine(db, bus, d, zerolog.Nop())
	e.Register(FilterGrabTrigger{})

	insertRule(t, db,
		"any grab",
		"filter_grab",
		`{"trigger":{"type":"filter_grab","config":{}}}`,
		`["dashboard"]`,
		0,
	)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	e.Start(ctx)
	defer e.Stop()

	// Wait for the subscriber.
	deadline := time.Now().Add(2 * time.Second)
	for bus.SubscriberCount(eventbus.TopicAutobrrGrab) == 0 && time.Now().Before(deadline) {
		time.Sleep(10 * time.Millisecond)
	}

	bus.Publish(eventbus.Event{
		Topic: eventbus.TopicAutobrrGrab,
		Payload: webhooks.AutobrrGrabEvent{
			FilterID: "1", FilterName: "F", ReleaseName: "R", InfoHash: "h",
			GrabbedAt: time.Now(), EndpointID: "autobrr",
		},
	})

	// Poll for the dashboard log row.
	deadline = time.Now().Add(2 * time.Second)
	var count int
	for time.Now().Before(deadline) {
		_ = db.QueryRow(`SELECT COUNT(*) FROM notification_log`).Scan(&count)
		if count > 0 {
			break
		}
		time.Sleep(20 * time.Millisecond)
	}
	if count == 0 {
		t.Fatal("expected at least one notification_log row")
	}
}

func TestEngineRespectsFilterScope(t *testing.T) {
	db := openRulesDB(t)
	bus := eventbus.New(zerolog.Nop())
	d := notifications.NewDispatcher(db, zerolog.Nop())
	e := NewEngine(db, bus, d, zerolog.Nop())
	e.Register(FilterGrabTrigger{})

	insertRule(t, db,
		"only filter 99",
		"filter_grab",
		`{"trigger":{"type":"filter_grab","config":{"filter_id":"99"}}}`,
		`["dashboard"]`,
		0,
	)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	e.Start(ctx)
	defer e.Stop()

	deadline := time.Now().Add(2 * time.Second)
	for bus.SubscriberCount(eventbus.TopicAutobrrGrab) == 0 && time.Now().Before(deadline) {
		time.Sleep(10 * time.Millisecond)
	}

	bus.Publish(eventbus.Event{
		Topic: eventbus.TopicAutobrrGrab,
		Payload: webhooks.AutobrrGrabEvent{
			FilterID: "1", FilterName: "F", ReleaseName: "R",
			GrabbedAt: time.Now(), EndpointID: "autobrr",
		},
	})
	time.Sleep(200 * time.Millisecond)

	var count int
	_ = db.QueryRow(`SELECT COUNT(*) FROM notification_log`).Scan(&count)
	if count != 0 {
		t.Fatalf("expected no rows (scoped filter mismatch); got %d", count)
	}
}

func TestRatioThresholdPeriodic(t *testing.T) {
	db := openRulesDB(t)
	_, _ = db.Exec(`INSERT INTO trackers VALUES ('mam','MAM','mam','x','{}',1,300,60,0,0,0)`)
	_, _ = db.Exec(`INSERT INTO ratio_snapshots (tracker_id, timestamp, real_ratio) VALUES ('mam', ?, ?)`,
		time.Now().Unix(), 0.3)

	tr := RatioThresholdTrigger{DB: db}
	notifs, err := tr.PeriodicEvaluate(context.Background(), map[string]any{
		"below":      0.5,
		"tracker_id": "mam",
	})
	if err != nil {
		t.Fatalf("PeriodicEvaluate: %v", err)
	}
	if len(notifs) != 1 {
		t.Fatalf("expected 1 notif, got %d", len(notifs))
	}
	if notifs[0].Severity != notifications.SeverityWarning {
		t.Errorf("severity: %v", notifs[0].Severity)
	}
}

func TestRatioThresholdNoFireWhenAboveThreshold(t *testing.T) {
	db := openRulesDB(t)
	_, _ = db.Exec(`INSERT INTO trackers VALUES ('mam','MAM','mam','x','{}',1,300,60,0,0,0)`)
	_, _ = db.Exec(`INSERT INTO ratio_snapshots (tracker_id, timestamp, real_ratio) VALUES ('mam', ?, ?)`,
		time.Now().Unix(), 1.5)
	tr := RatioThresholdTrigger{DB: db}
	notifs, _ := tr.PeriodicEvaluate(context.Background(), map[string]any{"below": 0.5})
	if len(notifs) != 0 {
		t.Errorf("expected 0 notifs, got %d", len(notifs))
	}
}

func TestCustomQueryRejectsMutation(t *testing.T) {
	tr := CustomQueryTrigger{DB: openRulesDB(t)}
	_, err := tr.PeriodicEvaluate(context.Background(), map[string]any{
		"query": "DELETE FROM trackers",
	})
	if err == nil {
		t.Fatal("expected non-SELECT to be rejected")
	}
}

func TestCustomQueryFiresWhenRowsReturned(t *testing.T) {
	db := openRulesDB(t)
	_, _ = db.Exec(`INSERT INTO trackers VALUES ('x','X','x','x','{}',1,300,60,0,0,0)`)
	tr := CustomQueryTrigger{DB: db}
	notifs, err := tr.PeriodicEvaluate(context.Background(), map[string]any{
		"query": "SELECT 1 FROM trackers WHERE id = 'x'",
		"title": "custom",
		"body":  "body",
	})
	if err != nil {
		t.Fatalf("PeriodicEvaluate: %v", err)
	}
	if len(notifs) != 1 || notifs[0].Title != "custom" {
		t.Errorf("notifs: %+v", notifs)
	}
}

func TestCooldownSuppresses(t *testing.T) {
	db := openRulesDB(t)
	bus := eventbus.New(zerolog.Nop())
	d := notifications.NewDispatcher(db, zerolog.Nop())
	e := NewEngine(db, bus, d, zerolog.Nop())
	e.Register(FilterGrabTrigger{})

	// Cooldown = 3600s; second fire within cooldown should be suppressed.
	insertRule(t, db, "any", "filter_grab",
		`{"trigger":{"type":"filter_grab","config":{}}}`,
		`["dashboard"]`, 3600)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	e.Start(ctx)
	defer e.Stop()

	deadline := time.Now().Add(2 * time.Second)
	for bus.SubscriberCount(eventbus.TopicAutobrrGrab) == 0 && time.Now().Before(deadline) {
		time.Sleep(10 * time.Millisecond)
	}

	grab := webhooks.AutobrrGrabEvent{FilterID: "1", GrabbedAt: time.Now(), EndpointID: "autobrr"}
	bus.Publish(eventbus.Event{Topic: eventbus.TopicAutobrrGrab, Payload: grab})
	time.Sleep(150 * time.Millisecond)
	bus.Publish(eventbus.Event{Topic: eventbus.TopicAutobrrGrab, Payload: grab})
	time.Sleep(300 * time.Millisecond)

	var count int
	_ = db.QueryRow(`SELECT COUNT(*) FROM notification_log WHERE channel = 'dashboard'`).Scan(&count)
	if count != 1 {
		t.Errorf("expected 1 dashboard row (cooldown suppresses 2nd), got %d", count)
	}
}
