// p2r01_test.go — HTTP-level coverage for the slim-shape REST surface
// landed in P2R-01: schedules, source-groups, repo-maintenance, the
// per-source-group Run-now endpoint, schedule_push reconciliation,
// and auto-init at hello.
package http

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	stdhttp "net/http"
	"strings"
	"testing"
	"time"

	"github.com/oklog/ulid/v2"

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

// loginAsAdmin creates an admin user + a session in the store and
// returns a cookie ready to attach to outgoing requests.
func loginAsAdmin(t *testing.T, st *store.Store) *stdhttp.Cookie {
	t.Helper()
	ctx := context.Background()
	uid := ulid.Make().String()
	hash, _ := auth.HashPassword("very-long-test-password")
	if err := st.CreateUser(ctx, store.User{
		ID: uid, Username: "tester-" + uid[:6],
		PasswordHash: hash, Role: store.RoleAdmin,
		CreatedAt: time.Now().UTC(),
	}); err != nil {
		t.Fatalf("create user: %v", err)
	}
	tok, _ := auth.NewToken()
	if err := st.CreateSession(ctx, store.Session{
		UserID:    uid,
		CreatedAt: time.Now().UTC(),
		ExpiresAt: time.Now().Add(time.Hour).UTC(),
	}, auth.HashToken(tok)); err != nil {
		t.Fatalf("create session: %v", err)
	}
	return &stdhttp.Cookie{Name: sessionCookieName, Value: tok}
}

// loginAsAdminWithID is like loginAsAdmin but also returns the user ID.
// Use this when tests need to assert that the user ID was recorded
// (e.g. on audit entries).
func loginAsAdminWithID(t *testing.T, st *store.Store) (*stdhttp.Cookie, string) {
	t.Helper()
	ctx := context.Background()
	uid := ulid.Make().String()
	hash, _ := auth.HashPassword("very-long-test-password")
	if err := st.CreateUser(ctx, store.User{
		ID: uid, Username: "tester-" + uid[:6],
		PasswordHash: hash, Role: store.RoleAdmin,
		CreatedAt: time.Now().UTC(),
	}); err != nil {
		t.Fatalf("create user: %v", err)
	}
	tok, _ := auth.NewToken()
	if err := st.CreateSession(ctx, store.Session{
		UserID:    uid,
		CreatedAt: time.Now().UTC(),
		ExpiresAt: time.Now().Add(time.Hour).UTC(),
	}, auth.HashToken(tok)); err != nil {
		t.Fatalf("create session: %v", err)
	}
	return &stdhttp.Cookie{Name: sessionCookieName, Value: tok}, uid
}

// makeHost inserts a minimal Host row directly via the store. Used by
// HTTP-level tests that don't want to go through the full enrollment
// path. Returns the host id.
func makeHost(t *testing.T, st *store.Store, name string) string {
	t.Helper()
	id := ulid.Make().String()
	if err := st.CreateHost(context.Background(), store.Host{
		ID: id, Name: name, OS: "linux", Arch: "amd64",
		ProtocolVersion: api.CurrentProtocolVersion,
		EnrolledAt:      time.Now().UTC(),
	}, "tokhash-"+id, ""); err != nil {
		t.Fatalf("create host: %v", err)
	}
	return id
}

// doJSON issues a JSON request with the given method and body, returns
// status + decoded JSON map (nil on empty body).
func doJSON(t *testing.T, baseURL, method, path string, body any, cookie *stdhttp.Cookie) (int, map[string]any) {
	t.Helper()
	var rdr io.Reader
	if body != nil {
		raw, _ := json.Marshal(body)
		rdr = bytes.NewReader(raw)
	}
	req, err := stdhttp.NewRequest(method, baseURL+path, rdr)
	if err != nil {
		t.Fatalf("new req: %v", err)
	}
	if rdr != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	if cookie != nil {
		req.AddCookie(cookie)
	}
	res, err := stdhttp.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer res.Body.Close()
	raw, _ := io.ReadAll(res.Body)
	if len(raw) == 0 {
		return res.StatusCode, nil
	}
	var out map[string]any
	if err := json.Unmarshal(raw, &out); err != nil {
		// Non-JSON (HTMX action paths return plain text on error).
		return res.StatusCode, map[string]any{"raw": string(raw)}
	}
	return res.StatusCode, out
}

// ----- source-groups ------------------------------------------------

func TestSourceGroupsCRUD(t *testing.T) {
	t.Parallel()
	_, url, st := newTestServerWithHub(t)
	cookie := loginAsAdmin(t, st)
	hostID := makeHost(t, st, "sg-host")

	// Empty list at start.
	status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/source-groups", nil, cookie)
	if status != 200 {
		t.Fatalf("list status: %d", status)
	}
	if got := body["source_groups"].([]any); len(got) != 0 {
		t.Fatalf("expected empty list, got %d", len(got))
	}

	// Create.
	status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
		map[string]any{
			"name":     "etc",
			"includes": []string{"/etc"},
			"excludes": []string{"/etc/shadow"},
			"retention_policy": map[string]int{
				"keep_daily": 7,
			},
			"retry_max":             3,
			"retry_backoff_seconds": 60,
		}, cookie)
	if status != 201 {
		t.Fatalf("create status: %d, body: %+v", status, body)
	}
	gid, _ := body["id"].(string)
	if gid == "" {
		t.Fatalf("create: no id returned: %+v", body)
	}

	// Duplicate name → 409.
	status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
		map[string]any{"name": "etc", "includes": []string{"/x"}}, cookie)
	if status != 409 {
		t.Errorf("duplicate name: want 409, got %d", status)
	}

	// Update — rename + add another include.
	status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/source-groups/"+gid,
		map[string]any{
			"name":     "system",
			"includes": []string{"/etc", "/var/log"},
			"retention_policy": map[string]int{
				"keep_daily":  14,
				"keep_weekly": 4,
			},
		}, cookie)
	if status != 200 {
		t.Fatalf("update status: %d, body: %+v", status, body)
	}
	if got := body["name"]; got != "system" {
		t.Errorf("rename: got %v want system", got)
	}

	// Delete.
	status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
	if status != 204 {
		t.Errorf("delete status: %d", status)
	}

	// Already gone.
	status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
	if status != 404 {
		t.Errorf("delete-after-delete: want 404, got %d", status)
	}
}

func TestSourceGroupDeleteRefusesIfInUse(t *testing.T) {
	t.Parallel()
	_, url, st := newTestServerWithHub(t)
	cookie := loginAsAdmin(t, st)
	hostID := makeHost(t, st, "sg-inuse-host")

	// Create a group via the store directly.
	gid := ulid.Make().String()
	if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
		ID: gid, HostID: hostID, Name: "default", Includes: []string{"/home"},
	}); err != nil {
		t.Fatalf("create group: %v", err)
	}
	// Attach a schedule.
	sid := ulid.Make().String()
	if err := st.CreateSchedule(context.Background(), &store.Schedule{
		ID: sid, HostID: hostID,
		CronExpr: "0 3 * * *", Enabled: true,
		SourceGroupIDs: []string{gid},
	}); err != nil {
		t.Fatalf("create schedule: %v", err)
	}

	status, body := doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
	if status != 409 {
		t.Fatalf("want 409, got %d body=%+v", status, body)
	}
	if body["code"] != "group_in_use" {
		t.Errorf("wrong code: %+v", body)
	}
}

// ----- schedules ----------------------------------------------------

func TestSchedulesCRUDValidation(t *testing.T) {
	t.Parallel()
	_, url, st := newTestServerWithHub(t)
	cookie := loginAsAdmin(t, st)
	hostID := makeHost(t, st, "sched-host")

	// Bad cron → 400.
	status, body := doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
		map[string]any{
			"cron": "not-a-cron", "enabled": true,
			"source_group_ids": []string{"x"},
		}, cookie)
	if status != 400 {
		t.Fatalf("bad cron: want 400, got %d (body=%+v)", status, body)
	}

	// Missing groups → 400.
	status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
		map[string]any{
			"cron": "0 3 * * *", "enabled": true,
			"source_group_ids": []string{},
		}, cookie)
	if status != 400 {
		t.Errorf("missing groups: want 400, got %d", status)
	}

	// Group not on host → 400.
	status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
		map[string]any{
			"cron": "0 3 * * *", "enabled": true,
			"source_group_ids": []string{"non-existent"},
		}, cookie)
	if status != 400 {
		t.Errorf("bogus group: want 400, got %d", status)
	}

	// Create a real group.
	gid := ulid.Make().String()
	if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
		ID: gid, HostID: hostID, Name: "default", Includes: []string{"/etc"},
	}); err != nil {
		t.Fatalf("group: %v", err)
	}

	// Happy create.
	status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
		map[string]any{
			"cron": "0 3 * * *", "enabled": true,
			"source_group_ids": []string{gid},
		}, cookie)
	if status != 201 {
		t.Fatalf("create: %d body=%+v", status, body)
	}
	sid, _ := body["id"].(string)
	if sid == "" {
		t.Fatalf("no id: %+v", body)
	}

	// List.
	status, body = doJSON(t, url, "GET", "/api/hosts/"+hostID+"/schedules", nil, cookie)
	if status != 200 {
		t.Fatalf("list: %d", status)
	}
	rows, _ := body["schedules"].([]any)
	if len(rows) != 1 {
		t.Fatalf("expected 1 schedule, got %d", len(rows))
	}

	// Update — change cron, keep group.
	status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/schedules/"+sid,
		map[string]any{
			"cron": "@hourly", "enabled": false,
			"source_group_ids": []string{gid},
		}, cookie)
	if status != 200 {
		t.Fatalf("update: %d body=%+v", status, body)
	}
	if body["cron"] != "@hourly" || body["enabled"] != false {
		t.Errorf("update fields: %+v", body)
	}

	// Delete.
	status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/schedules/"+sid, nil, cookie)
	if status != 204 {
		t.Errorf("delete: %d", status)
	}
}

// ----- repo-maintenance --------------------------------------------

func TestRepoMaintenanceGetSeedsAndPutValidates(t *testing.T) {
	t.Parallel()
	_, url, st := newTestServerWithHub(t)
	cookie := loginAsAdmin(t, st)
	hostID := makeHost(t, st, "maint-host")

	// GET on a host that hasn't had the row seeded auto-creates one.
	status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/repo-maintenance", nil, cookie)
	if status != 200 {
		t.Fatalf("get: %d body=%+v", status, body)
	}
	if body["host_id"] != hostID {
		t.Errorf("host_id mismatch: %+v", body)
	}

	// PUT with bad cron.
	status, _ = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
		map[string]any{
			"forget_cron": "junk", "prune_cron": "@weekly",
			"check_cron": "@monthly", "check_subset_pct": 10,
		}, cookie)
	if status != 400 {
		t.Errorf("bad cron: want 400, got %d", status)
	}

	// PUT with subset out of range.
	status, _ = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
		map[string]any{
			"forget_cron": "@daily", "prune_cron": "@weekly",
			"check_cron": "@monthly", "check_subset_pct": 200,
		}, cookie)
	if status != 400 {
		t.Errorf("bad subset: want 400, got %d", status)
	}

	// Happy PUT.
	status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
		map[string]any{
			"forget_cron":      "@daily",
			"forget_enabled":   true,
			"prune_cron":       "@weekly",
			"prune_enabled":    true,
			"check_cron":       "@monthly",
			"check_enabled":    false,
			"check_subset_pct": 25,
		}, cookie)
	if status != 200 {
		t.Fatalf("happy put: %d body=%+v", status, body)
	}
	if body["forget_cron"] != "@daily" || body["check_subset_pct"] != float64(25) {
		t.Errorf("fields: %+v", body)
	}
}

// ----- 410 Gone on retired routes ----------------------------------

func TestPerHostRunBackupReturns410(t *testing.T) {
	t.Parallel()
	_, url, st := newTestServerWithHub(t)
	cookie := loginAsAdmin(t, st)
	hostID := makeHost(t, st, "gone-host")

	req, _ := stdhttp.NewRequest("POST", url+"/hosts/"+hostID+"/run-backup", nil)
	req.AddCookie(cookie)
	res, err := stdhttp.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("post: %v", err)
	}
	defer res.Body.Close()
	if res.StatusCode != stdhttp.StatusGone {
		t.Errorf("want 410, got %d", res.StatusCode)
	}
}

// ----- schedule_push payload ---------------------------------------

func TestBuildScheduleSetPayload(t *testing.T) {
	t.Parallel()
	srv, _, st := newTestServerWithHub(t)
	hostID := makeHost(t, st, "push-host")

	gid := ulid.Make().String()
	keepDaily := 7
	if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
		ID: gid, HostID: hostID, Name: "default",
		Includes:        []string{"/etc", "/home"},
		Excludes:        []string{"/etc/shadow"},
		RetentionPolicy: store.RetentionPolicy{KeepDaily: &keepDaily},
		RetryMax:        2, RetryBackoffSeconds: 30,
	}); err != nil {
		t.Fatalf("group: %v", err)
	}
	sid := ulid.Make().String()
	if err := st.CreateSchedule(context.Background(), &store.Schedule{
		ID: sid, HostID: hostID,
		CronExpr: "0 3 * * *", Enabled: true,
		SourceGroupIDs: []string{gid},
	}); err != nil {
		t.Fatalf("schedule: %v", err)
	}

	payload, err := srv.buildScheduleSetPayload(context.Background(), hostID)
	if err != nil {
		t.Fatalf("build: %v", err)
	}
	if payload.Version == 0 {
		t.Fatalf("version should be > 0, got %d", payload.Version)
	}
	if len(payload.Schedules) != 1 {
		t.Fatalf("schedules: %d", len(payload.Schedules))
	}
	entry := payload.Schedules[0]
	if entry.ID != sid || entry.CronExpr != "0 3 * * *" || !entry.Enabled {
		t.Errorf("schedule fields: %+v", entry)
	}
	if len(entry.SourceGroups) != 1 {
		t.Fatalf("groups in schedule: %d", len(entry.SourceGroups))
	}
	g := entry.SourceGroups[0]
	if g.Name != "default" {
		t.Errorf("group name: %s", g.Name)
	}
	if !equalStrings(g.Includes, []string{"/etc", "/home"}) {
		t.Errorf("includes: %v", g.Includes)
	}
	var rp map[string]any
	_ = json.Unmarshal(g.RetentionPolicy, &rp)
	if rp["keep_daily"] != float64(7) {
		t.Errorf("retention: %+v", rp)
	}
}

// ----- per-source-group Run-now -----------------------------------

func TestRunSourceGroupOfflineHost(t *testing.T) {
	t.Parallel()
	_, url, st := newTestServerWithHub(t)
	cookie := loginAsAdmin(t, st)
	hostID := makeHost(t, st, "offline-host")

	gid := ulid.Make().String()
	if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
		ID: gid, HostID: hostID, Name: "default", Includes: []string{"/etc"},
	}); err != nil {
		t.Fatalf("group: %v", err)
	}

	// JSON path → 503 (host offline).
	req, _ := stdhttp.NewRequest("POST",
		url+"/hosts/"+hostID+"/source-groups/"+gid+"/run", nil)
	req.AddCookie(cookie)
	req.Header.Set("Accept", "application/json")
	res, err := stdhttp.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer res.Body.Close()
	if res.StatusCode != stdhttp.StatusServiceUnavailable {
		t.Errorf("offline: want 503, got %d", res.StatusCode)
	}
}

func TestRunSourceGroupUnknownGroup(t *testing.T) {
	t.Parallel()
	_, url, st := newTestServerWithHub(t)
	cookie := loginAsAdmin(t, st)
	hostID := makeHost(t, st, "noh-host")

	req, _ := stdhttp.NewRequest("POST",
		url+"/hosts/"+hostID+"/source-groups/no-such-gid/run", nil)
	req.AddCookie(cookie)
	req.Header.Set("Accept", "application/json")
	res, err := stdhttp.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer res.Body.Close()
	if res.StatusCode != stdhttp.StatusNotFound {
		t.Errorf("unknown group: want 404, got %d", res.StatusCode)
	}
}

// ----- helpers ----------------------------------------------------

func equalStrings(a, b []string) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

// keep fmt import live — used for occasional debug.
var (
	_ = fmt.Sprintf
	_ = strings.HasPrefix
)
