package store

import (
	"encoding/json"
	"io"
	"log"
	"path/filepath"
	"testing"
	"time"
)

func newTelemetryTestStore(t *testing.T) *Store {
	t.Helper()
	s, err := New(filepath.Join(t.TempDir(), "telemetry.db"), log.New(io.Discard, "", 0))
	if err != nil {
		t.Fatalf("store.New: %v", err)
	}
	t.Cleanup(func() { s.Close() })
	return s
}

// mkTelemetryRecord builds an AppTelemetryRecord through JSON — the same path a real
// controller report takes (also proves nil-safety when context is absent entirely).
func mkTelemetryRecord(t *testing.T, appName, severity, message string, lastSeen time.Time, context []string) AppTelemetryRecord {
	t.Helper()
	issue := map[string]interface{}{
		"severity":  severity,
		"message":   message,
		"count":     1,
		"last_seen": lastSeen.UTC().Format(time.RFC3339),
	}
	if context != nil {
		issue["context"] = context
	}
	b, _ := json.Marshal(map[string]interface{}{
		"app_name": appName,
		"issues":   []interface{}{issue},
	})
	var rec AppTelemetryRecord
	if err := json.Unmarshal(b, &rec); err != nil {
		t.Fatalf("unmarshal record: %v", err)
	}
	return rec
}

func saveIssue(t *testing.T, s *Store, customer, app, severity, msg string, lastSeen time.Time, context []string) {
	t.Helper()
	rec := mkTelemetryRecord(t, app, severity, msg, lastSeen, context)
	if err := s.SaveAppTelemetry(customer, time.Now(), []AppTelemetryRecord{rec}); err != nil {
		t.Fatalf("SaveAppTelemetry: %v", err)
	}
}

func getOneIssue(t *testing.T, s *Store, app string, since time.Time, includeDismissed bool) *AppIssue {
	t.Helper()
	issues, err := s.GetAppIssues(app, since, includeDismissed, 20)
	if err != nil {
		t.Fatalf("GetAppIssues: %v", err)
	}
	if len(issues) == 0 {
		return nil
	}
	return &issues[0]
}

var longAgo = time.Now().Add(-365 * 24 * time.Hour)

// Part C — context stored on INSERT with provenance; a later empty-context upsert does
// not clobber; a later DIFFERENT context does not churn it (first capture wins).
func TestUpsertIssue_ContextFirstCaptureWins(t *testing.T) {
	s := newTelemetryTestStore(t)
	msg := "ERROR: nfs mount /mnt/media lost"
	now := time.Now()

	saveIssue(t, s, "cust-a", "cwa", "error", msg, now, []string{"before line", "ERROR: nfs mount /mnt/media lost", "after line"})
	is := getOneIssue(t, s, "cwa", longAgo, false)
	if is == nil {
		t.Fatalf("issue not stored")
	}
	if len(is.Context) != 3 || is.Context[0] != "before line" {
		t.Fatalf("context not stored on insert: %#v", is.Context)
	}
	if is.ContextCustomer != "cust-a" {
		t.Fatalf("provenance = %q, want cust-a", is.ContextCustomer)
	}

	// A pre-v0.111 controller (no context field) re-reports the same issue: must not clobber.
	saveIssue(t, s, "cust-b", "cwa", "error", msg, now.Add(time.Minute), nil)
	is = getOneIssue(t, s, "cwa", longAgo, false)
	if len(is.Context) != 3 {
		t.Fatalf("empty-context upsert clobbered the stored context: %#v", is.Context)
	}
	if is.ContextCustomer != "cust-a" {
		t.Fatalf("provenance churned to %q", is.ContextCustomer)
	}
	// both customers recorded
	if len(is.AffectedCustomers) != 2 {
		t.Fatalf("affected customers = %v, want both", is.AffectedCustomers)
	}

	// A different context from another box: first capture still wins (no churn).
	saveIssue(t, s, "cust-b", "cwa", "error", msg, now.Add(2*time.Minute), []string{"other box context"})
	is = getOneIssue(t, s, "cwa", longAgo, false)
	if is.Context[0] != "before line" || is.ContextCustomer != "cust-a" {
		t.Fatalf("first-capture-wins violated: ctx=%#v from %q", is.Context, is.ContextCustomer)
	}
}

// Part C — an issue first seen WITHOUT context (old controller) adopts the first
// context that arrives later.
func TestUpsertIssue_LateContextAdopted(t *testing.T) {
	s := newTelemetryTestStore(t)
	msg := "ERROR: db locked"
	saveIssue(t, s, "cust-a", "gokapi", "error", msg, time.Now(), nil)
	is := getOneIssue(t, s, "gokapi", longAgo, false)
	if len(is.Context) != 0 {
		t.Fatalf("expected no context yet, got %#v", is.Context)
	}
	saveIssue(t, s, "cust-b", "gokapi", "error", msg, time.Now().Add(time.Minute), []string{"ctx line"})
	is = getOneIssue(t, s, "gokapi", longAgo, false)
	if len(is.Context) != 1 || is.Context[0] != "ctx line" || is.ContextCustomer != "cust-b" {
		t.Fatalf("late context not adopted: %#v from %q", is.Context, is.ContextCustomer)
	}
}

// Part F — the time-range filter: a 10-day-old issue is absent from the 24h view,
// present in the 30d view. Red-proof target: drop the last_seen >= since predicate →
// the 24h view shows the old row → this fails.
func TestGetAppIssues_RangeFilter(t *testing.T) {
	s := newTelemetryTestStore(t)
	tenDaysAgo := time.Now().Add(-10 * 24 * time.Hour)
	saveIssue(t, s, "cust-a", "vault", "error", "ERROR: smtp down", tenDaysAgo, nil)

	if got := getOneIssue(t, s, "vault", time.Now().Add(-24*time.Hour), false); got != nil {
		t.Fatalf("10d-old issue visible in the 24h view (last_seen=%s)", got.LastSeen)
	}
	if got := getOneIssue(t, s, "vault", time.Now().Add(-30*24*time.Hour), false); got == nil {
		t.Fatalf("10d-old issue missing from the 30d view")
	}
}

// Part G — dismissal semantics: a re-sent OLD window (last_seen <= dismissed_at) stays
// hidden; a genuinely NEW occurrence (last_seen > dismissed_at) resurfaces the row.
// Red-proof: drop the `excluded.last_seen > dismissed_at` guard in upsertAppIssue (make
// the conflict always clear dismissed_at) → the old-window re-report resurrects → FAIL.
func TestDismiss_OldWindowHidden_NewOccurrenceResurfaces(t *testing.T) {
	s := newTelemetryTestStore(t)
	msg := "ERROR: cwa nfs stale handle"
	oldSeen := time.Now().Add(-time.Hour)

	saveIssue(t, s, "cust-a", "cwa", "error", msg, oldSeen, nil)
	if n, err := s.DismissAppIssues("cwa"); err != nil || n != 1 {
		t.Fatalf("DismissAppIssues = %d/%v, want 1", n, err)
	}
	if got := getOneIssue(t, s, "cwa", longAgo, false); got != nil {
		t.Fatalf("dismissed issue still in the default view")
	}
	// visible with the toggle, flagged
	if got := getOneIssue(t, s, "cwa", longAgo, true); got == nil || got.DismissedAt == nil {
		t.Fatalf("show-dismissed view must expose the row with DismissedAt set")
	}

	// Old-window re-report (controller re-sends its rolling window with the SAME last_seen).
	saveIssue(t, s, "cust-a", "cwa", "error", msg, oldSeen, nil)
	if got := getOneIssue(t, s, "cwa", longAgo, false); got != nil {
		t.Fatalf("old-window re-report RESURRECTED the dismissed issue (last_seen=%s)", got.LastSeen)
	}

	// A genuinely NEW occurrence — recurrence must never be silently swallowed.
	newSeen := time.Now().Add(time.Minute)
	saveIssue(t, s, "cust-a", "cwa", "error", msg, newSeen, nil)
	got := getOneIssue(t, s, "cwa", longAgo, false)
	if got == nil {
		t.Fatalf("NEW occurrence did not resurface the dismissed issue")
	}
	if got.DismissedAt != nil {
		t.Fatalf("resurfaced issue still flagged dismissed")
	}
}

// Part D (store half) — request → pending; tail arrival stores, prunes to 2, and clears
// the request (consume-once). Red-proof: remove the request-DELETE in SaveAppLogTail →
// the pending request survives fulfillment → this fails.
func TestLogTail_RequestFulfillConsumeOnce(t *testing.T) {
	s := newTelemetryTestStore(t)

	if err := s.RequestLogTail("cust-a", "gokapi"); err != nil {
		t.Fatalf("RequestLogTail: %v", err)
	}
	// re-click refreshes, still one request
	if err := s.RequestLogTail("cust-a", "gokapi"); err != nil {
		t.Fatalf("RequestLogTail re-click: %v", err)
	}
	apps, err := s.GetPendingLogTailRequests("cust-a")
	if err != nil || len(apps) != 1 || apps[0] != "gokapi" {
		t.Fatalf("pending = %v/%v, want [gokapi]", apps, err)
	}
	// another customer sees nothing
	if other, _ := s.GetPendingLogTailRequests("cust-b"); len(other) != 0 {
		t.Fatalf("cross-customer pending leak: %v", other)
	}

	lines := []string{"first line", "second line", "third line"}
	if err := s.SaveAppLogTail("cust-a", "gokapi", time.Now(), lines); err != nil {
		t.Fatalf("SaveAppLogTail: %v", err)
	}
	// consume-once: fulfilled request cleared
	if apps, _ := s.GetPendingLogTailRequests("cust-a"); len(apps) != 0 {
		t.Fatalf("request survived fulfillment: %v — the ACK would re-request every cycle", apps)
	}
	tails, err := s.GetCustomerLogTails("cust-a")
	if err != nil || len(tails) != 1 {
		t.Fatalf("tails = %d/%v, want 1", len(tails), err)
	}
	if len(tails[0].Lines) != 3 || tails[0].Lines[0] != "first line" || tails[0].Lines[2] != "third line" {
		t.Fatalf("tail lines lost order: %#v", tails[0].Lines)
	}

	// keep-last-2 pruning
	s.SaveAppLogTail("cust-a", "gokapi", time.Now(), []string{"tail-2"})
	s.SaveAppLogTail("cust-a", "gokapi", time.Now(), []string{"tail-3"})
	tails, _ = s.GetCustomerLogTails("cust-a")
	if len(tails) != 2 {
		t.Fatalf("keep-last-2 violated: %d tails stored", len(tails))
	}
	if tails[0].Lines[0] != "tail-3" || tails[1].Lines[0] != "tail-2" {
		t.Fatalf("pruning kept the wrong tails: %v / %v", tails[0].Lines, tails[1].Lines)
	}

	// customer-scoped single read
	if tail, _ := s.GetLogTail(tails[0].ID, "cust-b"); tail != nil {
		t.Fatalf("cross-customer tail read must return nil")
	}
	if tail, _ := s.GetLogTail(tails[0].ID, "cust-a"); tail == nil || tail.AppName != "gokapi" {
		t.Fatalf("scoped tail read failed: %+v", tail)
	}
}

// Warn issues ride with no context and stay message-only end-to-end (nil-safe).
func TestUpsertIssue_WarnNoContext(t *testing.T) {
	s := newTelemetryTestStore(t)
	saveIssue(t, s, "cust-a", "app", "warn", "WARN: slow disk", time.Now(), nil)
	is := getOneIssue(t, s, "app", longAgo, false)
	if is == nil || len(is.Context) != 0 {
		t.Fatalf("warn issue context = %#v, want none", is)
	}
}

// Occurrence counting still works with the new upsert (regression guard).
func TestUpsertIssue_OccurrenceCount(t *testing.T) {
	s := newTelemetryTestStore(t)
	msg := "ERROR: boom"
	for i := 0; i < 3; i++ {
		saveIssue(t, s, "cust-a", "app", "error", msg, time.Now().Add(time.Duration(i)*time.Minute), nil)
	}
	is := getOneIssue(t, s, "app", longAgo, false)
	if is.OccurrenceCount != 3 {
		t.Fatalf("occurrence_count = %d, want 3", is.OccurrenceCount)
	}
}
