package scheduler

// Integration tests for coverageSweep against a real TimescaleDB. Guarded by
// OIKOS_TEST_DATABASE_URL — skipped when unset, same convention as
// internal/db/integration_test.go. Each run creates a throwaway database.

import (
	"context"
	"fmt"
	"math/rand"
	"os"
	"strings"
	"testing"

	"github.com/dtoro/oikos/internal/adapters/postgres"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
)

func newCoveragePool(t *testing.T) *db.Pool {
	t.Helper()
	baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
	if baseURL == "" {
		t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
	}
	ctx := context.Background()

	admin, err := pgx.Connect(ctx, baseURL)
	if err != nil {
		t.Fatalf("connect admin: %v", err)
	}
	dbName := fmt.Sprintf("oikos_cov_%08x", rand.Int63())
	if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
		admin.Close(ctx)
		t.Fatalf("create test db: %v", err)
	}
	admin.Close(ctx)

	at := strings.LastIndex(baseURL, "/")
	testURL := baseURL[:at+1] + dbName
	if q := strings.Index(baseURL[at:], "?"); q >= 0 {
		testURL += baseURL[at+q:]
	}

	pool, err := db.New(ctx, testURL)
	if err != nil {
		t.Fatalf("connect test db: %v", err)
	}
	if err := pool.Migrate(ctx); err != nil {
		t.Fatalf("migrate: %v", err)
	}
	t.Cleanup(func() {
		pool.Close()
		if admin, err := pgx.Connect(ctx, baseURL); err == nil {
			admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
			admin.Close(ctx)
		}
	})
	return pool
}

// fixture builds the four cases that matter, without the full seed:
// a declared+monitored type, a declared+unmonitored one, an explicitly
// unmonitorable one, and one that inherits its declaration from a parent.
func coverageFixture(t *testing.T, pool *db.Pool) map[string]uuid.UUID {
	t.Helper()
	ctx := context.Background()

	exec := func(sql string, args ...any) {
		t.Helper()
		if _, err := pool.Exec(ctx, sql, args...); err != nil {
			t.Fatalf("fixture %q: %v", sql, err)
		}
	}

	exec(`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, monitoring_spec, status)
	      VALUES ('t-container', NULL, true, 'compute', 'infrastructure', '["resource"]', 'active')
	      ON CONFLICT (name) DO UPDATE SET monitoring_spec = EXCLUDED.monitoring_spec`)
	exec(`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, monitoring_spec, status)
	      VALUES ('t-lxc', 't-container', false, 'compute', 'infrastructure', NULL, 'active')
	      ON CONFLICT (name) DO NOTHING`)
	exec(`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, monitoring_spec, status)
	      VALUES ('t-service', NULL, false, 'software', 'infrastructure', '["http"]', 'active')
	      ON CONFLICT (name) DO NOTHING`)
	exec(`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, monitoring_spec, status)
	      VALUES ('t-site', NULL, false, 'physical', 'infrastructure', '[]', 'active')
	      ON CONFLICT (name) DO NOTHING`)
	// `check` and `signal` normally arrive with the ontology seed, which this
	// fixture skips; the sweep creates signal entities and needs both.
	exec(`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, monitoring_spec, status)
	      VALUES ('check', NULL, false, 'operations', 'cognition', '[]', 'active')
	      ON CONFLICT (name) DO NOTHING`)
	exec(`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, monitoring_spec, status)
	      VALUES ('signal', NULL, false, 'operations', 'cognition', '[]', 'active')
	      ON CONFLICT (name) DO NOTHING`)

	ids := map[string]uuid.UUID{}
	for _, e := range []struct{ slug, typ string }{
		{"monitored-svc", "t-service"},   // declared + has a check
		{"unmonitored-svc", "t-service"}, // declared + no check  → signal
		{"inheriting-lxc", "t-lxc"},      // inherits [resource], no check → signal
		{"a-site", "t-site"},             // explicitly none → never a signal
	} {
		id := uuid.New()
		exec(`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
		      VALUES ($1,$2,$3,$2,'active','{}',1,now(),now())`, id, e.slug, e.typ)
		ids[e.slug] = id
	}

	// Only monitored-svc gets a check.
	checkID := uuid.New()
	exec(`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
	      VALUES ($1,'check:t:monitored-svc:0','check','c-monitored-svc','active','{}',1,now(),now())`, checkID)
	exec(`INSERT INTO check_defs (entity_id, target_id, kind, config, interval_s, timeout_s, enabled)
	      VALUES ($1,$2,'http','{}',60,30,true)`, checkID, ids["monitored-svc"])

	return ids
}

func openSignals(t *testing.T, pool *db.Pool, targetID uuid.UUID) (int, int) {
	t.Helper()
	var count, occurrences int
	err := pool.QueryRow(context.Background(),
		`SELECT count(*), COALESCE(max(occurrence_count),0) FROM signals
		 WHERE target_entity_id = $1 AND kind = $2 AND state NOT IN ('resolved','failed')`,
		targetID, UnmonitoredKind).Scan(&count, &occurrences)
	if err != nil {
		t.Fatalf("count signals: %v", err)
	}
	return count, occurrences
}

func TestCoverageSweepOnlyFlagsDeclaredButUnmonitored(t *testing.T) {
	pool := newCoveragePool(t)
	ids := coverageFixture(t, pool)
	ctx := context.Background()

	coverageSweep(ctx, pool)

	for _, c := range []struct {
		slug string
		want int
		why  string
	}{
		{"unmonitored-svc", 1, "declares http, has no check"},
		{"inheriting-lxc", 1, "inherits [resource] from its parent, has no check"},
		{"monitored-svc", 0, "has a check"},
		{"a-site", 0, "declares monitoring: none — flagging it would be a permanent false positive"},
	} {
		got, _ := openSignals(t, pool, ids[c.slug])
		if got != c.want {
			t.Errorf("%s (%s): %d open unmonitored signals, want %d", c.slug, c.why, got, c.want)
		}
	}
}

func TestCoverageSweepDedupsRatherThanDuplicating(t *testing.T) {
	pool := newCoveragePool(t)
	ids := coverageFixture(t, pool)
	ctx := context.Background()

	coverageSweep(ctx, pool)
	coverageSweep(ctx, pool)
	coverageSweep(ctx, pool)

	count, occurrences := openSignals(t, pool, ids["unmonitored-svc"])
	if count != 1 {
		t.Errorf("three sweeps produced %d signals, want 1", count)
	}
	if occurrences != 3 {
		t.Errorf("occurrence_count = %d after three sweeps, want 3", occurrences)
	}
}

func TestCoverageSweepResolvesWhenACheckAppears(t *testing.T) {
	pool := newCoveragePool(t)
	ids := coverageFixture(t, pool)
	ctx := context.Background()

	coverageSweep(ctx, pool)
	if count, _ := openSignals(t, pool, ids["unmonitored-svc"]); count != 1 {
		t.Fatalf("expected an open signal before the check is added, got %d", count)
	}

	// The entity acquires a check. The scheduler's normal auto-resolve keys on
	// the check entity id and only from 'raised', so it could never clear this.
	checkID := uuid.New()
	if _, err := pool.Exec(ctx,
		// entities has a unique (type, name), so this cannot reuse the
		// fixture check's name.
		`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
		 VALUES ($1,'check:t:unmonitored-svc:0','check','c-unmonitored-svc','active','{}',1,now(),now())`, checkID); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx,
		`INSERT INTO check_defs (entity_id, target_id, kind, config, interval_s, timeout_s, enabled)
		 VALUES ($1,$2,'http','{}',60,30,true)`, checkID, ids["unmonitored-svc"]); err != nil {
		t.Fatal(err)
	}

	coverageSweep(ctx, pool)

	if count, _ := openSignals(t, pool, ids["unmonitored-svc"]); count != 0 {
		t.Errorf("signal should have resolved once the entity had a check, %d still open", count)
	}
}

// Against the real ontology and inventory rather than a fixture: the sweep
// must stay silent for types that opted out and speak up for the genuine gaps.
// Asserted as properties, not exact counts, so it does not break every time
// the fleet changes.
func TestCoverageSweepAgainstTheRealSeed(t *testing.T) {
	pool := newCoveragePool(t)
	ctx := context.Background()

	for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
		content, err := os.ReadFile("../../seeds/" + f)
		if err != nil {
			t.Fatalf("read %s: %v", f, err)
		}
		err = pool.SeedIngest(ctx, f, content,
			func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
				switch f {
				case "ontology.yaml":
					_, err := db.IngestOntologySeed(ctx, tx, data)
					return err
				case "inventory.yaml":
					_, err := db.IngestInventorySeed(ctx, tx, data)
					return err
				default:
					_, err := db.IngestPolicySeed(ctx, tx, data)
					return err
				}
			})
		if err != nil {
			t.Fatalf("ingest %s: %v", f, err)
		}
	}

	coverageSweep(ctx, pool)

	flagged := map[string]int{}
	rows, err := pool.Query(ctx, `
		SELECT e.type, count(*)
		FROM signals s JOIN entities e ON e.id = s.target_entity_id
		WHERE s.kind = $1 AND s.state NOT IN ('resolved','failed')
		GROUP BY e.type`, UnmonitoredKind)
	if err != nil {
		t.Fatal(err)
	}
	for rows.Next() {
		var typ string
		var n int
		if err := rows.Scan(&typ, &n); err != nil {
			t.Fatal(err)
		}
		flagged[typ] = n
	}
	rows.Close()

	t.Logf("unmonitored signals by entity type: %v", flagged)

	// Declared `monitoring: none` — flagging these would be a permanent,
	// unresolvable false positive, which is the failure mode that would make
	// the signal worthless.
	for _, typ := range []string{"site", "lan", "mesh", "cluster"} {
		if n := flagged[typ]; n != 0 {
			t.Errorf("%s declares monitoring: none but %d were flagged unmonitored", typ, n)
		}
	}

	// Types that now get real checks must not be flagged either.
	for _, typ := range []string{"service", "lxc", "ingress-route", "proxmox-host"} {
		if n := flagged[typ]; n != 0 {
			t.Errorf("%s should be covered by checkdefaults, but %d were flagged", typ, n)
		}
	}

	// Genuine gaps: no `dns` checker, no edge from a pool to its machine.
	for _, typ := range []string{"dns-zone", "storage-pool"} {
		if flagged[typ] == 0 {
			t.Errorf("%s is a known gap and should have been flagged", typ)
		}
	}
}

// A resolved signal must be re-raisable. The (target, kind) partial unique
// index only covers open signals, so a resolved row does not conflict there —
// it collides on the primary key instead, which is why the upsert targets the
// PK.
func TestCoverageSweepReRaisesAfterResolution(t *testing.T) {
	pool := newCoveragePool(t)
	ids := coverageFixture(t, pool)
	ctx := context.Background()

	coverageSweep(ctx, pool)
	if _, err := pool.Exec(ctx,
		`UPDATE signals SET state='resolved' WHERE target_entity_id=$1 AND kind=$2`,
		ids["unmonitored-svc"], UnmonitoredKind); err != nil {
		t.Fatal(err)
	}

	coverageSweep(ctx, pool)

	count, _ := openSignals(t, pool, ids["unmonitored-svc"])
	if count != 1 {
		t.Errorf("a resolved signal should be re-raisable, got %d open", count)
	}
}
