package appbackup

import (
	"strings"
	"testing"
)

// classOf finds the resolved class+origin for a (root, relpath) in a ClassifiedBind slice.
func classOf(cbs []ClassifiedBind, root BindRoot, rel string) (BindClass, ClassOrigin, bool) {
	for _, c := range cbs {
		if c.Root == root && c.RelPath == rel {
			return c.Class, c.Origin, true
		}
	}
	return "", "", false
}

// --- Group A: classifier ---

// TestClassify_ImmichShape is Scenario A: explicit classes resolve, and an EXPLICIT entry beats the
// :ro reader-default (media/photos is :ro but ruled optional). Companion RP-2: making the ro-default
// override explicit entries forces media/photos to excluded and fails the optional assertion.
func TestClassify_ImmichShape(t *testing.T) {
	binds := []ComposeBind{
		{Root: RootHDD, RelPath: "appdata/immich", ReadOnly: false},
		{Root: RootUserdata, RelPath: "media/photos", ReadOnly: true}, // :ro external library
	}
	spec := &BackupSpec{
		HDD:      []BindSpec{{Path: "appdata/immich", Class: ClassMandatory}},
		Userdata: []BindSpec{{Path: "media/photos", Class: ClassOptional}},
	}
	cbs, has := ClassifyBinds(spec, binds)
	if !has {
		t.Fatal("hasClassification should be true with a spec present")
	}
	if cls, org, ok := classOf(cbs, RootHDD, "appdata/immich"); !ok || cls != ClassMandatory || org != OriginExplicit {
		t.Errorf("appdata/immich = %v/%v, want mandatory/explicit", cls, org)
	}
	// The crux: an explicit optional beats the :ro default_ro that would otherwise force excluded.
	if cls, org, ok := classOf(cbs, RootUserdata, "media/photos"); !ok || cls != ClassOptional || org != OriginExplicit {
		t.Errorf("media/photos (:ro, explicit optional) = %v/%v, want optional/explicit (explicit beats ro-default)", cls, org)
	}
}

// TestClassify_TwoLevelDefault is Scenario B: with a block PRESENT, an unlisted writable bind
// defaults mandatory (capture, the C6B-F1 direction) and an unlisted :ro bind defaults excluded
// (reader rule). Companion RP-3: flipping the unlisted-writable default to excluded fails the
// mandatory assertion.
func TestClassify_TwoLevelDefault(t *testing.T) {
	binds := []ComposeBind{
		{Root: RootHDD, RelPath: "appdata/app", ReadOnly: false},     // listed
		{Root: RootUserdata, RelPath: "data/extra", ReadOnly: false}, // UNLISTED writable
		{Root: RootUserdata, RelPath: "media/ro", ReadOnly: true},    // UNLISTED :ro
	}
	spec := &BackupSpec{HDD: []BindSpec{{Path: "appdata/app", Class: ClassMandatory}}}
	cbs, has := ClassifyBinds(spec, binds)
	if !has {
		t.Fatal("hasClassification should be true")
	}
	if cls, org, _ := classOf(cbs, RootUserdata, "data/extra"); cls != ClassMandatory || org != OriginDefaultWritable {
		t.Errorf("unlisted writable = %v/%v, want mandatory/default_writable (capture direction)", cls, org)
	}
	if cls, org, _ := classOf(cbs, RootUserdata, "media/ro"); cls != ClassExcluded || org != OriginDefaultRO {
		t.Errorf("unlisted :ro = %v/%v, want excluded/default_ro (reader rule)", cls, org)
	}
}

// TestClassify_NilSpecLegacy is Scenario C: a nil spec → every bind is legacy with no class, and
// hasClassification=false. This is the inertness gate at the classifier level.
func TestClassify_NilSpecLegacy(t *testing.T) {
	binds := []ComposeBind{{Root: RootUserdata, RelPath: "media", ReadOnly: true}}
	cbs, has := ClassifyBinds(nil, binds)
	if has {
		t.Error("nil spec must report hasClassification=false")
	}
	if len(cbs) != 1 || cbs[0].Origin != OriginLegacy || cbs[0].Class != "" {
		t.Errorf("nil-spec bind = %+v, want origin=legacy, empty class", cbs[0])
	}
}

// TestClassify_BareRootFallsToDefault: a bare-root bind (RelPath "") can't be matched by any explicit
// entry (empty paths are invalid), so it falls to the ro/writable default.
func TestClassify_BareRootFallsToDefault(t *testing.T) {
	spec := &BackupSpec{Userdata: []BindSpec{{Path: "media/x", Class: ClassOptional}}}
	cbs, _ := ClassifyBinds(spec, []ComposeBind{
		{Root: RootUserdata, RelPath: "", ReadOnly: false}, // bare ${USERDATA_PATH}
		{Root: RootHDD, RelPath: "", ReadOnly: true},       // bare ${HDD_PATH} :ro
	})
	if cls, org, _ := classOf(cbs, RootUserdata, ""); cls != ClassMandatory || org != OriginDefaultWritable {
		t.Errorf("bare writable root = %v/%v, want mandatory/default_writable", cls, org)
	}
	if cls, org, _ := classOf(cbs, RootHDD, ""); cls != ClassExcluded || org != OriginDefaultRO {
		t.Errorf("bare :ro root = %v/%v, want excluded/default_ro", cls, org)
	}
}

// TestClassify_SameRelPathBothRoots: userdata/x and hdd/x are DISTINCT binds — Root is part of
// identity, so an explicit hdd entry must not classify the userdata bind.
func TestClassify_SameRelPathBothRoots(t *testing.T) {
	binds := []ComposeBind{
		{Root: RootUserdata, RelPath: "shared", ReadOnly: false},
		{Root: RootHDD, RelPath: "shared", ReadOnly: false},
	}
	spec := &BackupSpec{HDD: []BindSpec{{Path: "shared", Class: ClassExcluded}}}
	cbs, _ := ClassifyBinds(spec, binds)
	if cls, org, _ := classOf(cbs, RootHDD, "shared"); cls != ClassExcluded || org != OriginExplicit {
		t.Errorf("hdd/shared = %v/%v, want excluded/explicit", cls, org)
	}
	if cls, org, _ := classOf(cbs, RootUserdata, "shared"); cls != ClassMandatory || org != OriginDefaultWritable {
		t.Errorf("userdata/shared = %v/%v, want mandatory/default_writable (hdd entry must NOT match it)", cls, org)
	}
}

// --- Group B: validation (Scenario D) — every defect rejects the WHOLE block; error names the entry ---

func TestValidateBackupSpec_Defects(t *testing.T) {
	// The compose binds the valid entries reference (so only the seeded defect is the failure).
	binds := []ComposeBind{
		{Root: RootUserdata, RelPath: "media/tv"},
		{Root: RootHDD, RelPath: "appdata/x"},
	}
	cases := []struct {
		name     string
		spec     *BackupSpec
		wantFrag string // substring the error must contain (the offending entry / rule)
	}{
		{"unknown class", &BackupSpec{Userdata: []BindSpec{{Path: "media/tv", Class: "keepit"}}}, "invalid class"},
		{"empty class (typoed key)", &BackupSpec{Userdata: []BindSpec{{Path: "media/tv", Class: ""}}}, "invalid class"},
		{"empty path", &BackupSpec{HDD: []BindSpec{{Path: "", Class: ClassMandatory}}}, "empty path"},
		{"absolute path", &BackupSpec{HDD: []BindSpec{{Path: "/etc/x", Class: ClassMandatory}}}, "absolute"},
		{"dotdot path", &BackupSpec{HDD: []BindSpec{{Path: "../escape", Class: ClassMandatory}}}, "escapes"},
		{"backslash path", &BackupSpec{HDD: []BindSpec{{Path: "appdata\\x", Class: ClassMandatory}}}, "backslash"},
		{"non-clean path", &BackupSpec{HDD: []BindSpec{{Path: "appdata/./x", Class: ClassMandatory}}}, "non-clean"},
		{"duplicate path", &BackupSpec{HDD: []BindSpec{
			{Path: "appdata/x", Class: ClassMandatory}, {Path: "appdata/x", Class: ClassExcluded},
		}}, "duplicate"},
		{"no matching bind (typo)", &BackupSpec{Userdata: []BindSpec{{Path: "media/tvv", Class: ClassExcluded}}}, "matches no compose bind"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := ValidateBackupSpec(tc.spec, binds)
			if err == nil {
				t.Fatalf("expected rejection, got nil")
			}
			if !strings.Contains(err.Error(), tc.wantFrag) {
				t.Errorf("error %q must contain %q", err.Error(), tc.wantFrag)
			}
		})
	}
}

// TestValidateBackupSpec_ValidAndNil: a clean block validates, and a nil spec is vacuously valid.
func TestValidateBackupSpec_ValidAndNil(t *testing.T) {
	binds := []ComposeBind{{Root: RootHDD, RelPath: "appdata/x"}, {Root: RootUserdata, RelPath: "media/tv"}}
	spec := &BackupSpec{
		HDD:      []BindSpec{{Path: "appdata/x", Class: ClassMandatory}},
		Userdata: []BindSpec{{Path: "media/tv", Class: ClassExcluded}},
	}
	if err := ValidateBackupSpec(spec, binds); err != nil {
		t.Errorf("clean block should validate: %v", err)
	}
	if err := ValidateBackupSpec(nil, binds); err != nil {
		t.Errorf("nil spec must be vacuously valid: %v", err)
	}
}
