package maintenance

import (
	"context"
	"errors"
	"testing"
	"time"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)

// fakeBackend implements Backend with table-driven canned responses.
type fakeBackend struct {
	rows []store.HostRepoMaintenance
	// jobs[hostID][kind] -> job (if present, returned). If absent,
	// fakeBackend returns ErrNotFound by default.
	jobs map[string]map[string]*store.Job
	// hardErr forces a non-ErrNotFound failure for a given (host, kind).
	hardErr map[string]map[string]error
	// listErr forces ListAllMaintenance to fail.
	listErr error
}

func (f *fakeBackend) ListAllMaintenance(_ context.Context) ([]store.HostRepoMaintenance, error) {
	if f.listErr != nil {
		return nil, f.listErr
	}
	return f.rows, nil
}

func (f *fakeBackend) LatestJobByKind(_ context.Context, hostID, kind string) (*store.Job, error) {
	if hostErrs, ok := f.hardErr[hostID]; ok {
		if err := hostErrs[kind]; err != nil {
			return nil, err
		}
	}
	if hostJobs, ok := f.jobs[hostID]; ok {
		if j := hostJobs[kind]; j != nil {
			return j, nil
		}
	}
	return nil, store.ErrNotFound
}

// mustTime parses an RFC3339 string, fatal on failure.
func mustTime(t *testing.T, s string) time.Time {
	t.Helper()
	out, err := time.Parse(time.RFC3339, s)
	if err != nil {
		t.Fatalf("parse %q: %v", s, err)
	}
	return out
}

func TestTickerSkipsDisabled(t *testing.T) {
	t.Parallel()
	be := &fakeBackend{
		rows: []store.HostRepoMaintenance{{
			HostID:        "h1",
			ForgetCron:    "0 3 * * *",
			ForgetEnabled: false,
			PruneCron:     "0 4 * * *",
			PruneEnabled:  false,
			CheckCron:     "0 5 * * *",
			CheckEnabled:  false,
		}},
	}
	tk := New(be)
	now := mustTime(t, "2026-05-04T04:00:00Z")
	got, err := tk.Decide(context.Background(), now)
	if err != nil {
		t.Fatalf("Decide: %v", err)
	}
	if len(got) != 0 {
		t.Errorf("expected no decisions, got %+v", got)
	}
}

func TestTickerSkipsEmptyCron(t *testing.T) {
	t.Parallel()
	be := &fakeBackend{
		rows: []store.HostRepoMaintenance{{
			HostID:        "h1",
			ForgetCron:    "",
			ForgetEnabled: true,
			PruneCron:     "",
			PruneEnabled:  true,
			CheckCron:     "",
			CheckEnabled:  true,
		}},
	}
	tk := New(be)
	got, err := tk.Decide(context.Background(), mustTime(t, "2026-05-04T04:00:00Z"))
	if err != nil {
		t.Fatalf("Decide: %v", err)
	}
	if len(got) != 0 {
		t.Errorf("expected no decisions, got %+v", got)
	}
}

func TestTickerFiresWhenOverdue(t *testing.T) {
	t.Parallel()
	now := mustTime(t, "2026-05-04T04:00:00Z")
	// Latest forget job 25h ago.
	last := now.Add(-25 * time.Hour)
	be := &fakeBackend{
		rows: []store.HostRepoMaintenance{{
			HostID:        "h1",
			ForgetCron:    "0 3 * * *",
			ForgetEnabled: true,
		}},
		jobs: map[string]map[string]*store.Job{
			"h1": {"forget": &store.Job{ID: "j1", HostID: "h1", Kind: "forget", CreatedAt: last}},
		},
	}
	tk := New(be)
	got, err := tk.Decide(context.Background(), now)
	if err != nil {
		t.Fatalf("Decide: %v", err)
	}
	if len(got) != 1 || got[0].Kind != "forget" || got[0].HostID != "h1" {
		t.Errorf("expected one forget decision, got %+v", got)
	}
}

func TestTickerSuppressesWhenRecent(t *testing.T) {
	t.Parallel()
	now := mustTime(t, "2026-05-04T04:00:00Z")
	last := mustTime(t, "2026-05-04T03:30:00Z")
	be := &fakeBackend{
		rows: []store.HostRepoMaintenance{{
			HostID:        "h1",
			ForgetCron:    "0 3 * * *",
			ForgetEnabled: true,
		}},
		jobs: map[string]map[string]*store.Job{
			"h1": {"forget": &store.Job{ID: "j1", HostID: "h1", Kind: "forget", CreatedAt: last}},
		},
	}
	tk := New(be)
	got, err := tk.Decide(context.Background(), now)
	if err != nil {
		t.Fatalf("Decide: %v", err)
	}
	if len(got) != 0 {
		t.Errorf("expected no decisions, got %+v", got)
	}
}

func TestTickerFirstRunAnchorBoundedAt24h(t *testing.T) {
	t.Parallel()
	be := &fakeBackend{
		rows: []store.HostRepoMaintenance{{
			HostID:        "h1",
			ForgetCron:    "0 3 * * *",
			ForgetEnabled: true,
		}},
	}
	tk := New(be)

	// Case 1: now=04:00. Anchor=04:00 - 24h = previous-day 04:00. Next
	// fire after that is today 03:00 — within window → fire.
	now1 := mustTime(t, "2026-05-04T04:00:00Z")
	got, err := tk.Decide(context.Background(), now1)
	if err != nil {
		t.Fatalf("Decide: %v", err)
	}
	if len(got) != 1 {
		t.Errorf("case1: expected 1 decision, got %+v", got)
	}

	// Case 2: a cron firing less often than once per 24h with a
	// no-prior-job anchor must not fire when the most recent fire is
	// outside the 24h lookback window. Use a weekly cron (Mondays at
	// 03:00) and `now` on a Tuesday: anchor=now-24h lands on Monday,
	// so cron.Next(Monday) = next-week Monday → after now → no fire.
	// 2026-05-04 is a Monday, 2026-05-05 a Tuesday.
	be2 := &fakeBackend{
		rows: []store.HostRepoMaintenance{{
			HostID:        "h2",
			ForgetCron:    "0 3 * * 1",
			ForgetEnabled: true,
		}},
	}
	tk2 := New(be2)
	now2 := mustTime(t, "2026-05-05T03:00:00Z")
	got2, err := tk2.Decide(context.Background(), now2)
	if err != nil {
		t.Fatalf("Decide case2: %v", err)
	}
	if len(got2) != 0 {
		t.Errorf("case2: expected no decisions (cron fires < once/24h, prior fire was Monday 03:00 which is exactly 24h ago and anchor=now-24h means next-after is next Monday), got %+v", got2)
	}
}

func TestTickerCheckDecisionCarriesSubset(t *testing.T) {
	t.Parallel()
	now := mustTime(t, "2026-05-04T04:00:00Z")
	last := now.Add(-30 * 24 * time.Hour)
	be := &fakeBackend{
		rows: []store.HostRepoMaintenance{{
			HostID:         "h1",
			CheckCron:      "0 3 * * *",
			CheckEnabled:   true,
			CheckSubsetPct: 25,
		}},
		jobs: map[string]map[string]*store.Job{
			"h1": {"check": &store.Job{ID: "j1", HostID: "h1", Kind: "check", CreatedAt: last}},
		},
	}
	tk := New(be)
	got, err := tk.Decide(context.Background(), now)
	if err != nil {
		t.Fatalf("Decide: %v", err)
	}
	if len(got) != 1 || got[0].Kind != "check" || got[0].SubsetPct != 25 {
		t.Errorf("expected check decision with SubsetPct=25, got %+v", got)
	}
}

func TestTickerHardJobErrorSkipsKind(t *testing.T) {
	t.Parallel()
	now := mustTime(t, "2026-05-04T04:00:00Z")
	last := now.Add(-25 * time.Hour)
	hardErr := errors.New("synthetic db error")
	be := &fakeBackend{
		rows: []store.HostRepoMaintenance{{
			HostID:        "h1",
			ForgetCron:    "0 3 * * *",
			ForgetEnabled: true,
			CheckCron:     "0 3 * * *",
			CheckEnabled:  true,
		}},
		jobs: map[string]map[string]*store.Job{
			// check has a normal latest-job; should still fire.
			"h1": {"check": &store.Job{ID: "jc", HostID: "h1", Kind: "check", CreatedAt: last}},
		},
		hardErr: map[string]map[string]error{
			"h1": {"forget": hardErr},
		},
	}
	tk := New(be)
	got, err := tk.Decide(context.Background(), now)
	if err != nil {
		t.Fatalf("Decide: %v", err)
	}
	// Only the check decision should land — forget is skipped.
	if len(got) != 1 || got[0].Kind != "check" {
		t.Errorf("expected only check decision, got %+v", got)
	}
}

func TestTickerHandlesMultipleHosts(t *testing.T) {
	t.Parallel()
	now := mustTime(t, "2026-05-04T04:00:00Z")
	last := now.Add(-25 * time.Hour)
	be := &fakeBackend{
		rows: []store.HostRepoMaintenance{
			{
				HostID:        "ha",
				ForgetCron:    "0 3 * * *",
				ForgetEnabled: true,
			},
			{
				HostID:       "hb",
				CheckCron:    "0 3 * * *",
				CheckEnabled: true,
				PruneCron:    "0 4 * * *",
				PruneEnabled: false, // disabled — should not fire
			},
		},
		jobs: map[string]map[string]*store.Job{
			"ha": {"forget": &store.Job{ID: "j1", HostID: "ha", Kind: "forget", CreatedAt: last}},
			"hb": {"check": &store.Job{ID: "j2", HostID: "hb", Kind: "check", CreatedAt: last}},
		},
	}
	tk := New(be)
	got, err := tk.Decide(context.Background(), now)
	if err != nil {
		t.Fatalf("Decide: %v", err)
	}
	if len(got) != 2 {
		t.Fatalf("expected 2 decisions, got %d: %+v", len(got), got)
	}
	kinds := map[string]string{}
	for _, d := range got {
		kinds[d.HostID] = d.Kind
	}
	if kinds["ha"] != "forget" {
		t.Errorf("ha: expected forget, got %q", kinds["ha"])
	}
	if kinds["hb"] != "check" {
		t.Errorf("hb: expected check, got %q", kinds["hb"])
	}
}

func TestTickerInvalidCronSkipsSilently(t *testing.T) {
	t.Parallel()
	be := &fakeBackend{
		rows: []store.HostRepoMaintenance{{
			HostID:        "h1",
			ForgetCron:    "not a cron",
			ForgetEnabled: true,
		}},
	}
	tk := New(be)
	got, err := tk.Decide(context.Background(), mustTime(t, "2026-05-04T04:00:00Z"))
	if err != nil {
		t.Fatalf("Decide: %v", err)
	}
	if len(got) != 0 {
		t.Errorf("expected no decisions for invalid cron, got %+v", got)
	}
}
