package policy

import (
	"context"
	"encoding/json"
	"strings"
	"testing"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"

	"github.com/open-policy-agent/opa/rego"
)

// TestRegoParity_AllInternalCases validates that the bundled reference
// Rego (ReferenceRego, embedded from rego/access.rego) produces the same
// allow/deny decision as the InternalDecider for every fixture below.
//
// This test imports the OPA Go module — but only in a _test.go file, so
// it does NOT end up in `go build ./cmd/zddc-server`'s production
// binary. Operators get a 13-MB OPA-free binary; CI gets a parity check
// that catches semantic drift between the two implementations the moment
// it occurs.
//
// The fixture set is intentionally inherited from acl_test.go so any new
// case added there (or a case the conversation surfaces while the docs
// are being maintained) is automatically picked up here.
func TestRegoParity_AllInternalCases(t *testing.T) {
	ctx := context.Background()

	// Compile the reference Rego once; query it for each fixture.
	preparedQuery, err := rego.New(
		rego.Query("data.zddc.access.allow"),
		rego.Module("access.rego", ReferenceRego),
	).PrepareForEval(ctx)
	if err != nil {
		t.Fatalf("rego compilation failed: %v", err)
	}

	allow := func(p ...string) zddc.ZddcFile {
		return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}}
	}
	deny := func(p ...string) zddc.ZddcFile {
		return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}}
	}
	allowDeny := func(a, d []string) zddc.ZddcFile {
		return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: a, Deny: d}}
	}
	empty := zddc.ZddcFile{}

	cases := []struct {
		name  string
		chain zddc.PolicyChain
		email string
		path  string
	}{
		// All canonical cascade cases from acl_test.go — kept as a parallel
		// list rather than imported because acl_test.go's helpers aren't
		// exported and we want this file to be self-contained.
		{"empty chain no files", zddc.PolicyChain{HasAnyFile: false}, "alice@example.com", "/"},
		{"empty chain no files, anon", zddc.PolicyChain{HasAnyFile: false}, "", "/"},
		{"files exist no rule matches", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true}, "alice@example.com", "/"},
		{"leaf allow wins", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
		{"leaf deny beats parent allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), deny("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
		{"leaf no rule, parent allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), allow("bob@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
		{"leaf re-allows what parent denied", zddc.PolicyChain{Levels: []zddc.ZddcFile{deny("alice@example.com"), allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
		{"multi-level deepest wins", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), allowDeny([]string{"*@example.com"}, []string{"alice@example.com"}), allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/sub/"},
		{"empty levels, files present, deny", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, empty, empty}, HasAnyFile: true}, "alice@example.com", "/sub/"},
		{"empty levels, no files, allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, empty, empty}, HasAnyFile: false}, "alice@example.com", "/sub/"},

		// Glob-pattern parity — exercises email_matches in Rego against
		// MatchesPattern in Go.
		{"wildcard local part", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@*")}, HasAnyFile: true}, "alice@anywhere.com", "/"},
		{"wildcard domain", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com")}, HasAnyFile: true}, "anyone@example.com", "/"},
		{"exact match", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/"},
		{"exact does not match anyone else", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@example.com")}, HasAnyFile: true}, "bob@example.com", "/"},
		{"wildcard does NOT cross @", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*example.com")}, HasAnyFile: true}, "alice@example.com", "/"},
		{"bare * matches anyone", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*")}, HasAnyFile: true}, "alice@example.com", "/"},

		// Worked-example layout traces (verify-it recipe in zddc/README.md).
		// Insider on technical project.
		{"insider on technical", zddc.PolicyChain{Levels: []zddc.ZddcFile{
			zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
			allow("*@mycompany.com"),
		}, HasAnyFile: true}, "alice@mycompany.com", "/Acme-tech/"},
		// Insider not on closed project.
		{"insider not on closed", zddc.PolicyChain{Levels: []zddc.ZddcFile{
			zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
			allow("alice@mycompany.com"),
		}, HasAnyFile: true}, "bob@mycompany.com", "/Acme-comm/"},
		// Vendor at /Archive/Acme/.
		{"vendor at own folder", zddc.PolicyChain{Levels: []zddc.ZddcFile{
			zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
			allow("*@mycompany.com"),
			allow("acme-rep@acme.com"),
		}, HasAnyFile: true}, "acme-rep@acme.com", "/Archive/Acme/"},
		// Vendor blocked at sibling-vendor folder.
		{"vendor blocked at sibling", zddc.PolicyChain{Levels: []zddc.ZddcFile{
			zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
			allow("*@mycompany.com"),
			allow("beta-rep@beta.com"),
		}, HasAnyFile: true}, "acme-rep@acme.com", "/Archive/Beta/"},

		// The anti-pattern trap: same-level allow + deny *@company.com
		// blocks the supposedly-allowed user too (deny is checked first
		// within a level, so this is a documentation/rego parity check).
		{"trap same-level allow+deny shadow", zddc.PolicyChain{Levels: []zddc.ZddcFile{
			zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
			allowDeny([]string{"alice@mycompany.com"}, []string{"*@mycompany.com"}),
		}, HasAnyFile: true}, "alice@mycompany.com", "/Trap/"},
	}

	internal := &InternalDecider{}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			input := AllowInput{Path: tc.path, PolicyChain: chainToSerializable(tc.chain)}
			input.User.Email = tc.email

			// Run the Go evaluator.
			goAllow, err := internal.Allow(ctx, input)
			if err != nil {
				t.Fatalf("internal: %v", err)
			}

			// Run the Rego evaluator on identical input.
			regoInput, err := canonicalInput(input)
			if err != nil {
				t.Fatalf("encode input: %v", err)
			}
			rs, err := preparedQuery.Eval(ctx, rego.EvalInput(regoInput))
			if err != nil {
				t.Fatalf("rego eval: %v", err)
			}
			if len(rs) == 0 {
				t.Fatalf("rego: no result")
			}
			regoAllow, ok := rs[0].Expressions[0].Value.(bool)
			if !ok {
				t.Fatalf("rego: result is not bool: %v", rs[0].Expressions[0].Value)
			}

			if goAllow != regoAllow {
				t.Errorf("PARITY BROKEN: internal=%v, rego=%v\n  email=%q path=%q\n  chain=%+v",
					goAllow, regoAllow, tc.email, tc.path, tc.chain)
			}
		})
	}
}

// canonicalInput serializes the AllowInput through JSON and back so that
// the Rego evaluator sees the exact same shape an external OPA would
// receive over the wire. Catches accidents where a struct-literal
// fixture would expose a Go-only field name (capitalized) that wouldn't
// reach a real OPA deployment.
func canonicalInput(in AllowInput) (map[string]interface{}, error) {
	b, err := json.Marshal(in)
	if err != nil {
		return nil, err
	}
	var out map[string]interface{}
	if err := json.NewDecoder(strings.NewReader(string(b))).Decode(&out); err != nil {
		return nil, err
	}
	return out, nil
}
