package tagsweep

import (
	"context"
	"fmt"
	"os/exec"
	"path/filepath"
	"reflect"
	"testing"

	"github.com/postfix/okworkspace/internal/repo"
	"github.com/postfix/okworkspace/internal/store"
)

// seedJob inserts a row directly into the jobs table for testing SweepStatus.
func seedJob(t *testing.T, st *store.Store, kind, status string) {
	t.Helper()
	if _, err := st.DB().Exec(
		`INSERT INTO jobs (kind, payload, status, run_after, attempts)
		 VALUES (?, '', ?, 0, 0)`, kind, status); err != nil {
		t.Fatalf("seed job kind=%q status=%q: %v", kind, status, err)
	}
}

// newTestStore wires a real tagsweep Store over a real content repo + migrated
// SQLite store (mirrors the graph package's t.TempDir() harness convention). It
// returns the tagsweep Store and the underlying *store.Store so a test can seed
// page_tags / re-run Migrate directly.
func newTestStore(t *testing.T) (*Store, *store.Store, *repo.Repo) {
	t.Helper()
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git binary not available")
	}
	base := t.TempDir()

	r, err := repo.New(filepath.Join(base, "repo"))
	if err != nil {
		t.Fatalf("repo.New: %v", err)
	}
	t.Cleanup(func() { _ = r.Close() })

	st, err := store.Open(filepath.Join(base, "app.db"))
	if err != nil {
		t.Fatalf("store.Open: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })
	if err := st.Migrate(context.Background()); err != nil {
		t.Fatalf("store.Migrate: %v", err)
	}

	ts := OpenStore(st.DB())
	ts.SetRepo(r)
	return ts, st, r
}

// writePage writes a minimal .md page to the content repo (path enumeration is
// all Targets needs — bodies are irrelevant to the store).
func writePage(t *testing.T, r *repo.Repo, path string) {
	t.Helper()
	content := fmt.Sprintf("---\ntype: page\ntitle: %s\n---\n\nbody\n", path)
	if err := r.Write(path, []byte(content)); err != nil {
		t.Fatalf("write page %q: %v", path, err)
	}
}

// seedTag inserts a page_tags row so a page counts as tagged.
func seedTag(t *testing.T, st *store.Store, pagePath, tag string) {
	t.Helper()
	if _, err := st.DB().Exec(`INSERT INTO page_tags (page_path, tag) VALUES (?, ?)`, pagePath, tag); err != nil {
		t.Fatalf("seed page_tags %q: %v", pagePath, err)
	}
}

// TestMigrationIdempotent asserts migration 0010 applies idempotently: a second
// Migrate is a no-op and version 10 is recorded exactly once.
func TestMigrationIdempotent(t *testing.T) {
	_, st, _ := newTestStore(t)
	ctx := context.Background()

	if err := st.Migrate(ctx); err != nil {
		t.Fatalf("second Migrate: %v", err)
	}
	var n int
	if err := st.DB().QueryRow(
		`SELECT COUNT(1) FROM schema_migrations WHERE version=?`, 10).Scan(&n); err != nil {
		t.Fatalf("query schema_migrations: %v", err)
	}
	if n != 1 {
		t.Fatalf("schema_migrations rows for version 10 = %d, want 1", n)
	}
}

// TestStagePendingRoundTrip asserts StagePending then ListPending round-trips the
// suggestions (tag + existing flag) and base_revision.
func TestStagePendingRoundTrip(t *testing.T) {
	ts, _, _ := newTestStore(t)
	ctx := context.Background()

	sugg := []Suggestion{{Tag: "ops", Existing: true}, {Tag: "runbook", Existing: false}}
	if err := ts.StagePending(ctx, "a.md", sugg, "rev-1"); err != nil {
		t.Fatalf("StagePending: %v", err)
	}

	got, err := ts.ListPending(ctx)
	if err != nil {
		t.Fatalf("ListPending: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("ListPending len = %d, want 1", len(got))
	}
	want := PendingEntry{PagePath: "a.md", Suggestions: sugg, BaseRevision: "rev-1"}
	if !reflect.DeepEqual(got[0], want) {
		t.Fatalf("ListPending[0] = %+v, want %+v", got[0], want)
	}
}

// TestStagePendingSupersedes asserts re-staging the same page yields exactly one
// pending row (the last suggestion wins).
func TestStagePendingSupersedes(t *testing.T) {
	ts, st, _ := newTestStore(t)
	ctx := context.Background()

	if err := ts.StagePending(ctx, "a.md", []Suggestion{{Tag: "old"}}, "rev-1"); err != nil {
		t.Fatalf("StagePending #1: %v", err)
	}
	if err := ts.StagePending(ctx, "a.md", []Suggestion{{Tag: "new", Existing: true}}, "rev-2"); err != nil {
		t.Fatalf("StagePending #2: %v", err)
	}

	var n int
	if err := st.DB().QueryRow(
		`SELECT COUNT(1) FROM tag_suggestions WHERE page_path=? AND status='pending'`, "a.md").Scan(&n); err != nil {
		t.Fatalf("count pending: %v", err)
	}
	if n != 1 {
		t.Fatalf("pending rows for a.md = %d, want 1 (supersede)", n)
	}

	got, err := ts.ListPending(ctx)
	if err != nil {
		t.Fatalf("ListPending: %v", err)
	}
	if len(got) != 1 || len(got[0].Suggestions) != 1 || got[0].Suggestions[0].Tag != "new" || got[0].BaseRevision != "rev-2" {
		t.Fatalf("after supersede got %+v, want the second suggestion (new/rev-2)", got)
	}
}

// TestListPendingOrdered asserts ListPending returns entries ordered by page_path.
func TestListPendingOrdered(t *testing.T) {
	ts, _, _ := newTestStore(t)
	ctx := context.Background()

	if err := ts.StagePending(ctx, "z.md", []Suggestion{{Tag: "z"}}, "r"); err != nil {
		t.Fatalf("StagePending z: %v", err)
	}
	if err := ts.StagePending(ctx, "a.md", []Suggestion{{Tag: "a"}}, "r"); err != nil {
		t.Fatalf("StagePending a: %v", err)
	}

	got, err := ts.ListPending(ctx)
	if err != nil {
		t.Fatalf("ListPending: %v", err)
	}
	if len(got) != 2 || got[0].PagePath != "a.md" || got[1].PagePath != "z.md" {
		t.Fatalf("ListPending order = %v, want [a.md z.md]", []string{got[0].PagePath, got[1].PagePath})
	}
}

// TestTargetsUntagged asserts Targets(false) returns live pages absent from
// page_tags, and that a tagged-but-deleted page is never targeted.
func TestTargetsUntagged(t *testing.T) {
	ts, st, r := newTestStore(t)
	ctx := context.Background()

	writePage(t, r, "a.md") // untagged → target
	writePage(t, r, "b.md") // tagged → not a target
	writePage(t, r, "c.md") // untagged → target
	seedTag(t, st, "b.md", "ops")
	// A tag row for a page that does NOT exist on disk: must never be targeted.
	seedTag(t, st, "ghost.md", "ops")

	got, err := ts.Targets(ctx, false)
	if err != nil {
		t.Fatalf("Targets(false): %v", err)
	}
	want := []string{"a.md", "c.md"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Targets(false) = %v, want %v", got, want)
	}
}

// TestTargetsAll asserts Targets(true) returns every live page (tagged + untagged)
// and still excludes a tagged-but-deleted page.
func TestTargetsAll(t *testing.T) {
	ts, st, r := newTestStore(t)
	ctx := context.Background()

	writePage(t, r, "a.md")
	writePage(t, r, "b.md")
	seedTag(t, st, "b.md", "ops")
	seedTag(t, st, "ghost.md", "ops") // not on disk → excluded

	got, err := ts.Targets(ctx, true)
	if err != nil {
		t.Fatalf("Targets(true): %v", err)
	}
	want := []string{"a.md", "b.md"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Targets(true) = %v, want %v", got, want)
	}
}

// TestTargetsZeroWhenAllTagged asserts Targets(false) returns an empty slice (not
// an error) when every live page is already tagged.
func TestTargetsZeroWhenAllTagged(t *testing.T) {
	ts, st, r := newTestStore(t)
	ctx := context.Background()

	writePage(t, r, "a.md")
	writePage(t, r, "b.md")
	seedTag(t, st, "a.md", "ops")
	seedTag(t, st, "b.md", "ops")

	got, err := ts.Targets(ctx, false)
	if err != nil {
		t.Fatalf("Targets(false): %v", err)
	}
	if len(got) != 0 {
		t.Fatalf("Targets(false) with all tagged = %v, want empty", got)
	}
}

// TestTargetsNilRepoEmpty asserts a nil repo yields an empty slice (no panic).
func TestTargetsNilRepoEmpty(t *testing.T) {
	ts, _, _ := newTestStore(t)
	ts.SetRepo(nil)
	got, err := ts.Targets(context.Background(), true)
	if err != nil {
		t.Fatalf("Targets(true) nil repo: %v", err)
	}
	if len(got) != 0 {
		t.Fatalf("Targets with nil repo = %v, want empty", got)
	}
}

// TestGetPendingReturnsStagedRow asserts GetPending returns a page's staged
// suggestions + base_revision, and (false,nil) when there is no pending row —
// the server reads the STAGED base_revision from here (never a client value).
func TestGetPendingReturnsStagedRow(t *testing.T) {
	ts, _, _ := newTestStore(t)
	ctx := context.Background()

	sugg := []Suggestion{{Tag: "ops", Existing: true}}
	if err := ts.StagePending(ctx, "a.md", sugg, "rev-staged"); err != nil {
		t.Fatalf("StagePending: %v", err)
	}

	got, ok, err := ts.GetPending(ctx, "a.md")
	if err != nil {
		t.Fatalf("GetPending: %v", err)
	}
	if !ok {
		t.Fatal("GetPending found=false, want true")
	}
	want := PendingEntry{PagePath: "a.md", Suggestions: sugg, BaseRevision: "rev-staged"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("GetPending = %+v, want %+v", got, want)
	}

	// Absent page → (_, false, nil).
	_, ok, err = ts.GetPending(ctx, "missing.md")
	if err != nil {
		t.Fatalf("GetPending missing: %v", err)
	}
	if ok {
		t.Fatal("GetPending missing found=true, want false")
	}
}

// TestResolvePendingFlipsOnlyNamed asserts ResolvePending flips only the named
// pending rows to resolved (others stay pending; ListPending shrinks).
func TestResolvePendingFlipsOnlyNamed(t *testing.T) {
	ts, _, _ := newTestStore(t)
	ctx := context.Background()

	for _, p := range []string{"a.md", "b.md", "c.md"} {
		if err := ts.StagePending(ctx, p, []Suggestion{{Tag: "ops"}}, "r"); err != nil {
			t.Fatalf("StagePending %q: %v", p, err)
		}
	}

	if err := ts.ResolvePending(ctx, []string{"a.md", "c.md"}); err != nil {
		t.Fatalf("ResolvePending: %v", err)
	}

	got, err := ts.ListPending(ctx)
	if err != nil {
		t.Fatalf("ListPending: %v", err)
	}
	if len(got) != 1 || got[0].PagePath != "b.md" {
		t.Fatalf("after resolve ListPending = %+v, want only [b.md]", got)
	}

	// The resolved pages are no longer pending (GetPending returns false).
	if _, ok, _ := ts.GetPending(ctx, "a.md"); ok {
		t.Fatal("a.md still pending after ResolvePending")
	}
	if _, ok, _ := ts.GetPending(ctx, "b.md"); !ok {
		t.Fatal("b.md was wrongly resolved")
	}
}

// TestResolvePendingEmptyNoop asserts an empty slice is a no-op (no error, no
// rows touched).
func TestResolvePendingEmptyNoop(t *testing.T) {
	ts, _, _ := newTestStore(t)
	ctx := context.Background()
	if err := ts.StagePending(ctx, "a.md", []Suggestion{{Tag: "ops"}}, "r"); err != nil {
		t.Fatalf("StagePending: %v", err)
	}
	if err := ts.ResolvePending(ctx, nil); err != nil {
		t.Fatalf("ResolvePending(nil): %v", err)
	}
	got, _ := ts.ListPending(ctx)
	if len(got) != 1 {
		t.Fatalf("empty ResolvePending changed pending count: %+v", got)
	}
}

// TestSweepStatusGroupsByStatus asserts SweepStatus returns correct counts for
// each job status, scoped to tag_suggest jobs only.
func TestSweepStatusGroupsByStatus(t *testing.T) {
	ts, st, _ := newTestStore(t)
	ctx := context.Background()

	// Seed jobs: 2 pending, 1 running, 3 done, 1 failed — all tag_suggest.
	seedJob(t, st, KindTagSuggest, statusPending)
	seedJob(t, st, KindTagSuggest, statusPending)
	seedJob(t, st, KindTagSuggest, statusRunning)
	seedJob(t, st, KindTagSuggest, statusDone)
	seedJob(t, st, KindTagSuggest, statusDone)
	seedJob(t, st, KindTagSuggest, statusDone)
	seedJob(t, st, KindTagSuggest, statusFailed)
	// A non-tag_suggest job must NOT be counted.
	seedJob(t, st, "other_kind", statusPending)

	got, err := ts.SweepStatus(ctx)
	if err != nil {
		t.Fatalf("SweepStatus: %v", err)
	}
	if got.Queued != 2 {
		t.Fatalf("Queued = %d, want 2", got.Queued)
	}
	if got.Processing != 1 {
		t.Fatalf("Processing = %d, want 1", got.Processing)
	}
	if got.Done != 3 {
		t.Fatalf("Done = %d, want 3", got.Done)
	}
	if got.Failed != 1 {
		t.Fatalf("Failed = %d, want 1", got.Failed)
	}
	if got.Total != 7 {
		t.Fatalf("Total = %d, want 7", got.Total)
	}
}

// TestSweepStatusNilDB asserts a nil db returns zero progress (no panic).
func TestSweepStatusNilDB(t *testing.T) {
	s := &Store{db: nil}
	got, err := s.SweepStatus(context.Background())
	if err != nil {
		t.Fatalf("SweepStatus nil db: %v", err)
	}
	if got.Total != 0 {
		t.Fatalf("nil db Total = %d, want 0", got.Total)
	}
}

// TestSweepStatusEmpty asserts zero tag_suggest jobs returns all zeros.
func TestSweepStatusEmpty(t *testing.T) {
	ts, _, _ := newTestStore(t)
	got, err := ts.SweepStatus(context.Background())
	if err != nil {
		t.Fatalf("SweepStatus empty: %v", err)
	}
	if got.Total != 0 {
		t.Fatalf("empty Total = %d, want 0", got.Total)
	}
}

// TestPendingApprovalCount asserts it returns the count of pending tag_suggestions.
func TestPendingApprovalCount(t *testing.T) {
	ts, _, _ := newTestStore(t)
	ctx := context.Background()

	n, err := ts.PendingApprovalCount(ctx)
	if err != nil {
		t.Fatalf("PendingApprovalCount (empty): %v", err)
	}
	if n != 0 {
		t.Fatalf("empty PendingApprovalCount = %d, want 0", n)
	}

	if err := ts.StagePending(ctx, "a.md", []Suggestion{{Tag: "ops"}}, "r1"); err != nil {
		t.Fatalf("StagePending a: %v", err)
	}
	if err := ts.StagePending(ctx, "b.md", []Suggestion{{Tag: "dev"}}, "r2"); err != nil {
		t.Fatalf("StagePending b: %v", err)
	}

	n, err = ts.PendingApprovalCount(ctx)
	if err != nil {
		t.Fatalf("PendingApprovalCount: %v", err)
	}
	if n != 2 {
		t.Fatalf("PendingApprovalCount = %d, want 2", n)
	}
}

// TestPendingApprovalCountNilDB asserts nil db returns 0 (no panic).
func TestPendingApprovalCountNilDB(t *testing.T) {
	s := &Store{db: nil}
	n, err := s.PendingApprovalCount(context.Background())
	if err != nil {
		t.Fatalf("PendingApprovalCount nil db: %v", err)
	}
	if n != 0 {
		t.Fatalf("nil db count = %d, want 0", n)
	}
}
