package store

import (
	"encoding/json"
	"os"
	"reflect"
	"regexp"
	"sort"
	"strings"
	"testing"
)

// The two halves as the agent (host) and controller (app) emit them — keys must match the cross-repo
// golden (the agent's host-report.golden.json dr_recipe section + the controller's emitter).
const drHostHalf = `{
  "recipe_version": 1,
  "guests": [ { "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 } ],
  "pbs": { "repo_id": "felhom-pbs", "namespace": "root", "namespace_state": "resolved", "latest_snapshot_id": "9201" },
  "drives": [ { "durable_id": "uuid:da9e7089-cf8e-4617-adcb-a377743fae00", "mount_path": "/mnt/felhom-usb", "intent": "enrolled", "total_bytes": 1000000000000 } ],
  "pve_storage": [ { "name": "local-lvm", "type": "lvmthin", "content": "rootdir,images" }, { "name": "felhom-usb", "type": "usb", "content": "backup" } ],
  "backup_target": { "state": "resolved", "storage_id": "felhom-usb", "mount_path": "/mnt/felhom-usb" }
}`

const drAppHalf = `{
  "recipe_version": 1,
  "customer": { "id": "cust-demo", "display": "Demo Customer", "domain": "demo-felhom.eu" },
  "apps": [ { "catalog_ref": "romm", "enabled": true, "storage_bindings": [ { "container_path": "/roms", "drive": "felhom-flash", "subpath": "userdata/roms" } ] } ],
  "offsite_restic": { "host": "u629488-sub1.your-storagebox.de", "user": "u629488-sub1", "port": 23, "repo_path": "/home/felhom-repo" }
}`

// capturedHostHalf / capturedAppHalf are the halves demo-felhom's controller and agent REALLY stored,
// read verbatim out of the hub's own dr_recipe table on 2026-07-30 (customer_id='demo-felhom'), with only
// the app list truncated for length. They exist because drHostHalf/drAppHalf above are hand-written and
// OMIT sections production supplies — and that omission is exactly why R-122 survived: `offsite_restic`
// was stored for every real customer and silently dropped by the assembly, with a green suite throughout.
//
// capturedHostHalf carries the POST-FIX host half (agent v0.118.0: backup_target + namespace_state); the
// pre-fix one is quoted in the CHANGELOG entry for comparison.
const capturedHostHalf = `{
  "recipe_version": 1,
  "guests": [ { "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 } ],
  "pbs": { "repo_id": "felhom-pbs", "namespace": "demo-felhom", "namespace_state": "resolved", "latest_snapshot_id": "9201" },
  "drives": [ { "durable_id": "uuid:47a3361a-91e0-4831-a69d-27f540ed3f48", "mount_path": "/mnt/hdd_1", "intent": "enrolled", "total_bytes": 983351140352 } ],
  "pve_storage": [
    { "name": "local-lvm", "type": "lvmthin", "content": "images,rootdir" },
    { "name": "felhom-backup", "type": "local-dir", "content": "backup" },
    { "name": "felhom-pbs", "type": "pbs", "content": "backup" },
    { "name": "local", "type": "local", "content": "backup,import,vztmpl,iso" }
  ],
  "backup_target": { "state": "resolved", "storage_id": "felhom-backup", "mount_path": "/mnt/hdd_1" }
}`

const capturedAppHalf = `{
  "recipe_version": 1,
  "customer": { "id": "demo-felhom", "display": "Demo Ügyfél", "domain": "demo-felhom.eu" },
  "apps": [
    { "catalog_ref": "bookstack", "enabled": true, "storage_bindings": [] },
    { "catalog_ref": "immich", "enabled": true, "storage_bindings": [
      { "container_path": "/external/photos", "drive": "hdd_1", "subpath": "userdata/media/photos" } ] }
  ],
  "offsite_restic": { "host": "u629488-sub1.your-storagebox.de", "user": "u629488-sub1", "port": 23, "repo_path": "/home/felhom-repo" }
}`

// TestDRRecipe_StoreRoundTrip: each half upserts independently and preserves the other; GetDRRecipe
// returns both.
func TestDRRecipe_StoreRoundTrip(t *testing.T) {
	s := newTestStore(t)

	// App half lands first.
	if err := s.SaveDRRecipeAppHalf("cust-demo", 1, []byte(drAppHalf)); err != nil {
		t.Fatal(err)
	}
	rec, _ := s.GetDRRecipe("cust-demo")
	if rec == nil || rec.AppHalfJSON == "" || rec.HostHalfJSON != "" {
		t.Fatalf("after app-half: want app set, host empty, got %+v", rec)
	}

	// Host half lands later — must NOT clobber the app half.
	if err := s.SaveDRRecipeHostHalf("cust-demo", "host-01", 1, []byte(drHostHalf)); err != nil {
		t.Fatal(err)
	}
	rec, _ = s.GetDRRecipe("cust-demo")
	if rec == nil || rec.AppHalfJSON == "" || rec.HostHalfJSON == "" || rec.HostID != "host-01" {
		t.Fatalf("after host-half: both halves must be present + host_id set, got %+v", rec)
	}

	// A re-report of the host half preserves the app half (and vice-versa).
	if err := s.SaveDRRecipeHostHalf("cust-demo", "host-01", 1, []byte(drHostHalf)); err != nil {
		t.Fatal(err)
	}
	rec, _ = s.GetDRRecipe("cust-demo")
	if rec.AppHalfJSON == "" {
		t.Fatal("re-reporting the host half clobbered the app half")
	}

	// Absent customer → nil, no error.
	if got, err := s.GetDRRecipe("nobody"); err != nil || got != nil {
		t.Fatalf("absent customer should be (nil,nil), got (%v,%v)", got, err)
	}
}

// TestAssembleDRRecipe_MatchesGolden: assembling both halves yields the golden's key shape (the
// cross-repo wire pin) and the correct stitched values.
func TestAssembleDRRecipe_MatchesGolden(t *testing.T) {
	rec := &DRRecipe{CustomerID: "cust-demo", RecipeVersion: 1, HostHalfJSON: drHostHalf, AppHalfJSON: drAppHalf}
	asm, err := AssembleDRRecipe(rec)
	if err != nil {
		t.Fatal(err)
	}
	b, _ := json.Marshal(asm)
	var got map[string]any
	json.Unmarshal(b, &got)

	raw, err := os.ReadFile("testdata/dr-recipe.golden.json")
	if err != nil {
		t.Fatal(err)
	}
	var golden map[string]any
	if err := json.Unmarshal(raw, &golden); err != nil {
		t.Fatalf("golden invalid: %v", err)
	}

	// Top-level key set must match the golden (the assembled wire shape).
	if ga, gb := keysOf(golden), keysOf(got); !reflect.DeepEqual(ga, gb) {
		t.Errorf("assembled key drift:\n golden=%v\n got   =%v", ga, gb)
	}
	// And the stitched values: customer from the app half, drives/pbs from the host half.
	if asm.RecipeVersion != 1 {
		t.Errorf("recipe_version=%d want 1", asm.RecipeVersion)
	}
	if !jsonContains(t, asm.Customer, "cust-demo") || !jsonContains(t, asm.Customer, "demo-felhom.eu") {
		t.Errorf("customer not stitched from app half: %s", asm.Customer)
	}
	if !jsonContains(t, asm.Drives, "uuid:da9e7089-cf8e-4617-adcb-a377743fae00") {
		t.Errorf("drives not stitched from host half: %s", asm.Drives)
	}
	if !jsonContains(t, asm.Apps, "romm") {
		t.Errorf("apps not stitched from app half: %s", asm.Apps)
	}
}

// TestAssembleDRRecipe_V1DriveShape is the regression guard for the v1 host-half drive shape
// (agent v0.39.0 dropped role + restic_repo_coord): a stored host half whose drives carry NEITHER
// field, but WHICH HAS a pbs block, must assemble cleanly — pbs present, drives passed through
// verbatim (RawMessage passthrough means the hub needs no struct change for the dropped fields).
func TestAssembleDRRecipe_V1DriveShape(t *testing.T) {
	const v1Host = `{
	  "recipe_version": 1,
	  "guests": [ { "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 } ],
	  "pbs": { "repo_id": "felhom-pbs", "namespace": "root", "latest_snapshot_id": "9201" },
	  "drives": [ { "durable_id": "uuid:da9e7089", "mount_path": "/mnt/felhom-usb", "intent": "enrolled", "total_bytes": 1000000000000 } ],
	  "pve_storage": [ { "name": "felhom-usb", "type": "usb", "content": "backup" } ]
	}`
	asm, err := AssembleDRRecipe(&DRRecipe{CustomerID: "c", RecipeVersion: 1, HostHalfJSON: v1Host})
	if err != nil {
		t.Fatalf("v1 host half failed to assemble: %v", err)
	}
	// pbs must survive the stitch.
	if !jsonContains(t, asm.PBS, "felhom-pbs") || !jsonContains(t, asm.PBS, "9201") {
		t.Errorf("pbs coord not carried through assembly: %s", asm.PBS)
	}
	// drives passed through verbatim, and carry NEITHER dropped field.
	if !jsonContains(t, asm.Drives, "uuid:da9e7089") {
		t.Errorf("drives not passed through: %s", asm.Drives)
	}
	if strings.Contains(string(asm.Drives), "role") || strings.Contains(string(asm.Drives), "restic_repo_coord") {
		t.Errorf("v1 drives must not carry role/restic_repo_coord: %s", asm.Drives)
	}
}

// TestAssembleDRRecipe_IgnoreUnknownAndVersionSkew: a half carrying an UNKNOWN top-level field and a
// HIGHER recipe_version still assembles (forward-compat), and recipe_version reflects the max.
func TestAssembleDRRecipe_IgnoreUnknownAndVersionSkew(t *testing.T) {
	futureHost := `{ "recipe_version": 2, "drives": [], "pve_storage": [], "guests": [],
		"future_section": { "whatever": 1 }, "network_topology": ["a","b"] }`
	rec := &DRRecipe{CustomerID: "c", RecipeVersion: 2, HostHalfJSON: futureHost, AppHalfJSON: drAppHalf}
	asm, err := AssembleDRRecipe(rec)
	if err != nil {
		t.Fatalf("ignore-unknown failed to parse a forward-compat half: %v", err)
	}
	if asm.RecipeVersion != 2 {
		t.Errorf("recipe_version=%d, want max(2,1)=2", asm.RecipeVersion)
	}
	// The unknown sections are dropped (not in AssembledRecipe), but the assembly did not error.
	b, _ := json.Marshal(asm)
	if string(b) == "" {
		t.Fatal("empty assembly")
	}
}

// TestAssembleDRRecipe_PartialHalves: only one half present → assemble what we have, no error.
func TestAssembleDRRecipe_PartialHalves(t *testing.T) {
	onlyApp, err := AssembleDRRecipe(&DRRecipe{AppHalfJSON: drAppHalf})
	if err != nil || onlyApp.Apps == nil || onlyApp.Drives != nil {
		t.Errorf("only-app assembly wrong: %+v err=%v", onlyApp, err)
	}
	onlyHost, err := AssembleDRRecipe(&DRRecipe{HostHalfJSON: drHostHalf})
	if err != nil || onlyHost.Drives == nil || onlyHost.Customer != nil {
		t.Errorf("only-host assembly wrong: %+v err=%v", onlyHost, err)
	}
	empty, err := AssembleDRRecipe(nil)
	if err != nil || empty.RecipeVersion != 1 {
		t.Errorf("nil assembly should be a v1 empty recipe, got %+v err=%v", empty, err)
	}
}

// TestAssembleDRRecipe_NoSecrets: defense-in-depth — the assembled output carries no credential-shaped
// key. (The load-bearing boundary is enforced at the controller emitter; this guards the hub side.)
func TestAssembleDRRecipe_NoSecrets(t *testing.T) {
	asm, _ := AssembleDRRecipe(&DRRecipe{HostHalfJSON: drHostHalf, AppHalfJSON: drAppHalf})
	b, _ := json.Marshal(asm)
	re := regexp.MustCompile(`(?i)(password|secret|token|hash|passphrase|api[_-]?key|\bkey\b|enc:)`)
	var v any
	json.Unmarshal(b, &v)
	var walk func(any)
	walk = func(n any) {
		switch x := n.(type) {
		case map[string]any:
			for k, c := range x {
				if re.MatchString(k) {
					t.Errorf("secret-shaped key %q in assembled recipe", k)
				}
				walk(c)
			}
		case []any:
			for _, c := range x {
				walk(c)
			}
		}
	}
	walk(v)
}

// TestAssembleDRRecipe_CarriesEveryEmittedSection is the guard the allow-list needed and never had.
//
// It asserts the CONSEQUENCE — what an operator downloading the recipe actually receives — against the
// halves production really stores, not hand-written structs. Every section either emitter produces must
// survive to the assembled output. When the hostHalfShape/appHalfShape allow-lists and the emitters drift
// apart, this fails; that drift is what dropped `offsite_restic` for the entire life of the feature
// (R-122) and would have dropped `backup_target` on the day R-109 shipped.
func TestAssembleDRRecipe_CarriesEveryEmittedSection(t *testing.T) {
	asm, err := AssembleDRRecipe(&DRRecipe{
		CustomerID: "demo-felhom", RecipeVersion: 1,
		HostHalfJSON: capturedHostHalf, AppHalfJSON: capturedAppHalf,
	})
	if err != nil {
		t.Fatalf("captured real halves failed to assemble: %v", err)
	}
	b, _ := json.Marshal(asm)
	var got map[string]any
	if err := json.Unmarshal(b, &got); err != nil {
		t.Fatal(err)
	}
	// Every section BOTH emitters produce today. A new section added to either half without a row here
	// (and in the shape struct) is the defect this test exists to catch.
	for _, section := range []string{
		"recipe_version", "customer", "guests", "pbs", "drives", "pve_storage", "backup_target",
		"apps", "offsite_restic",
	} {
		if _, ok := got[section]; !ok {
			t.Errorf("section %q was DROPPED by the assembly — the operator's recipe does not contain it "+
				"(add it to hostHalfShape/appHalfShape AND AssembledRecipe)", section)
		}
	}
	// And the values, not just the keys: a present-but-empty section is the same gap wearing a hat.
	if !jsonContains(t, asm.BackupTarget, "felhom-backup") || !jsonContains(t, asm.BackupTarget, "/mnt/hdd_1") {
		t.Errorf("backup_target reached the operator without naming the live target: %s", asm.BackupTarget)
	}
	if !jsonContains(t, asm.OffsiteRestic, "u629488-sub1.your-storagebox.de") {
		t.Errorf("offsite_restic reached the operator without the repo host: %s", asm.OffsiteRestic)
	}
	if !jsonContains(t, asm.PBS, "demo-felhom") {
		t.Errorf("pbs coord lost the per-customer namespace: %s", asm.PBS)
	}
}

// TestAssembleDRRecipe_NamesTheLiveBackupTargetAmongTwoCandidates is the R-109 consequence at the DELIVERY
// boundary: the recipe an operator downloads for a box with two content=backup storages must name the live
// one unambiguously. Assembly-level, because a correct host half that the hub drops helps nobody.
func TestAssembleDRRecipe_NamesTheLiveBackupTargetAmongTwoCandidates(t *testing.T) {
	asm, err := AssembleDRRecipe(&DRRecipe{HostHalfJSON: capturedHostHalf, AppHalfJSON: capturedAppHalf})
	if err != nil {
		t.Fatal(err)
	}
	// The fixture must still pose the problem: two plausible content=backup storages.
	var storages []struct{ Name, Type, Content string }
	if err := json.Unmarshal(asm.PVEStorage, &storages); err != nil {
		t.Fatal(err)
	}
	candidates := 0
	for _, s := range storages {
		if strings.Contains(s.Content, "backup") && (s.Type == "local-dir" || s.Type == "local") {
			candidates++
		}
	}
	if candidates < 2 {
		t.Fatalf("fixture no longer poses the R-109 problem: %d content=backup dir storages", candidates)
	}

	var bt struct {
		State     string `json:"state"`
		StorageID string `json:"storage_id"`
		MountPath string `json:"mount_path"`
	}
	if len(asm.BackupTarget) == 0 {
		t.Fatal("backup_target absent from the delivered recipe — the restorer must still guess (R-109)")
	}
	if err := json.Unmarshal(asm.BackupTarget, &bt); err != nil {
		t.Fatal(err)
	}
	if bt.State != "resolved" || bt.StorageID != "felhom-backup" || bt.MountPath != "/mnt/hdd_1" {
		t.Errorf("delivered backup_target = %+v, want resolved/felhom-backup at /mnt/hdd_1", bt)
	}
	if bt.StorageID == "local" || bt.MountPath == "/var/lib/vz" {
		t.Errorf("delivered recipe names the FROZEN target %q at %q", bt.StorageID, bt.MountPath)
	}
}

// TestAssembleDRRecipe_UnknownBackupTargetSurvivesVerbatim: the agent's explicit unknown must reach the
// operator AS an unknown. Passing it through as a resolved-looking section, or dropping it so the recipe
// merely lacks the field, would both turn "I could not tell" into something else.
func TestAssembleDRRecipe_UnknownBackupTargetSurvivesVerbatim(t *testing.T) {
	const unknownHost = `{
	  "recipe_version": 1, "guests": [], "drives": [], "pve_storage": [],
	  "backup_target": { "state": "unknown", "reason": "agent_backup_config_unavailable" }
	}`
	asm, err := AssembleDRRecipe(&DRRecipe{HostHalfJSON: unknownHost})
	if err != nil {
		t.Fatal(err)
	}
	if !jsonContains(t, asm.BackupTarget, "unknown") ||
		!jsonContains(t, asm.BackupTarget, "agent_backup_config_unavailable") {
		t.Errorf("the unknown state did not survive assembly: %s", asm.BackupTarget)
	}
	// It must not have acquired a target on the way through.
	if strings.Contains(string(asm.BackupTarget), "storage_id") {
		t.Errorf("assembly invented a storage_id for an unknown target: %s", asm.BackupTarget)
	}
}

func jsonContains(t *testing.T, raw json.RawMessage, substr string) bool {
	t.Helper()
	return len(raw) > 0 && string(raw) != "null" && strings.Contains(string(raw), substr)
}

func keysOf(m map[string]any) []string {
	ks := make([]string, 0, len(m))
	for k := range m {
		ks = append(ks, k)
	}
	sort.Strings(ks)
	return ks
}
