package policy

import (
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

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

// TestNew_ModeSelection: New() picks the right implementation per URL.
// External-mode URLs return a cachingDecider wrapping an HTTPDecider
// by default; CacheTTL<0 disables the wrapper.
func TestNew_ModeSelection(t *testing.T) {
	cases := []struct {
		url      string
		ttl      time.Duration
		wantType string
		wantErr  bool
	}{
		{"", 0, "*policy.InternalDecider", false},
		{"internal", 0, "*policy.InternalDecider", false},
		{"INTERNAL", 0, "*policy.InternalDecider", false},
		{"http://127.0.0.1:8181", 0, "*policy.cachingDecider", false},
		{"https://opa.example:8181", 0, "*policy.cachingDecider", false},
		{"unix:///run/opa.sock", 0, "*policy.cachingDecider", false},
		{"http://127.0.0.1:8181", -1, "*policy.HTTPDecider", false}, // cache disabled
		{"ftp://nope", 0, "", true},
		{"garbage", 0, "", true},
	}
	for _, tc := range cases {
		t.Run(tc.url, func(t *testing.T) {
			d, err := New(Config{URL: tc.url, CacheTTL: tc.ttl})
			if tc.wantErr {
				if err == nil {
					t.Fatal("New() = nil error, want error")
				}
				return
			}
			if err != nil {
				t.Fatalf("New() unexpected error: %v", err)
			}
			got := describe(d)
			if got != tc.wantType {
				t.Errorf("New(%q, ttl=%v) → %s, want %s", tc.url, tc.ttl, got, tc.wantType)
			}
		})
	}
}

func describe(v interface{}) string {
	switch v.(type) {
	case *InternalDecider:
		return "*policy.InternalDecider"
	case *HTTPDecider:
		return "*policy.HTTPDecider"
	case *cachingDecider:
		return "*policy.cachingDecider"
	default:
		return "unknown"
	}
}

// TestInternalDecider_ParityWithAllowedWithChain: the internal
// decider returns the same answer as zddc.AllowedWithChain for
// every documented cascade scenario.
func TestInternalDecider_ParityWithAllowedWithChain(t *testing.T) {
	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
		want  bool
	}{
		{
			"empty chain, no files → allow",
			zddc.PolicyChain{HasAnyFile: false},
			"alice@example.com",
			true,
		},
		{
			"files exist but no rule matches → deny",
			zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
			"alice@example.com",
			false,
		},
		{
			"leaf allow wins",
			zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
			"alice@example.com",
			true,
		},
		{
			"leaf deny beats parent allow (bottom-up first match)",
			zddc.PolicyChain{Levels: []zddc.ZddcFile{
				allow("*@example.com"),
				deny("alice@example.com"),
			}, HasAnyFile: true},
			"alice@example.com",
			false,
		},
		{
			"leaf has no rule for user, falls back to parent allow",
			zddc.PolicyChain{Levels: []zddc.ZddcFile{
				allow("*@example.com"),
				allow("bob@example.com"),
			}, HasAnyFile: true},
			"alice@example.com",
			true,
		},
		{
			"leaf allows user that parent denies",
			zddc.PolicyChain{Levels: []zddc.ZddcFile{
				deny("alice@example.com"),
				allow("alice@example.com"),
			}, HasAnyFile: true},
			"alice@example.com",
			true,
		},
		{
			"multi-level: deepest match 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",
			true,
		},
	}
	d := &InternalDecider{}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := AllowFromChain(context.Background(), d, tc.chain, tc.email, "/test")
			if err != nil {
				t.Fatalf("AllowFromChain: %v", err)
			}
			want := zddc.AllowedWithChain(tc.chain, tc.email)
			if got != want {
				t.Errorf("decider = %v, AllowedWithChain = %v (parity broken)", got, want)
			}
			if got != tc.want {
				t.Errorf("decider = %v, want %v", got, tc.want)
			}
		})
	}
}

// TestHTTPDecider_HappyPath: the HTTP decider posts the canonical
// OPA shape and acts on a 200 with {"result": true|false}.
func TestHTTPDecider_HappyPath(t *testing.T) {
	var captured struct {
		path        string
		contentType string
		body        []byte
	}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		captured.path = r.URL.Path
		captured.contentType = r.Header.Get("Content-Type")
		captured.body, _ = io.ReadAll(r.Body)
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"result": true}`))
	}))
	defer srv.Close()

	d, err := New(Config{URL: srv.URL})
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	in := AllowInput{Path: "/p"}
	in.User.Email = "alice@example.com"
	in.PolicyChain = &SerializableChain{HasAnyFile: true}

	got, err := d.Allow(context.Background(), in)
	if err != nil {
		t.Fatalf("Allow: %v", err)
	}
	if got != true {
		t.Errorf("Allow = false, want true")
	}
	if captured.path != "/v1/data/zddc/access/allow" {
		t.Errorf("path = %q, want /v1/data/zddc/access/allow", captured.path)
	}
	if !strings.HasPrefix(captured.contentType, "application/json") {
		t.Errorf("content-type = %q, want application/json", captured.contentType)
	}
	// Body should be {"input": {...}}
	var wrap struct{ Input AllowInput }
	if err := json.Unmarshal(captured.body, &wrap); err != nil {
		t.Fatalf("body did not unmarshal as {input}: %v (body=%s)", err, captured.body)
	}
	if wrap.Input.User.Email != "alice@example.com" {
		t.Errorf("body.input.user.email = %q, want alice@example.com", wrap.Input.User.Email)
	}
}

// TestHTTPDecider_FailClosed: any transport/encoding/HTTP error
// returns deny (false) by default, with no Go error returned to
// the caller (handlers don't have to special-case it).
func TestHTTPDecider_FailClosed(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "internal error", http.StatusInternalServerError)
	}))
	defer srv.Close()

	d, err := New(Config{URL: srv.URL})
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
	if err != nil {
		t.Fatalf("Allow: %v", err)
	}
	if got != false {
		t.Errorf("on 500, Allow = true, want false (fail-closed default)")
	}
}

// TestHTTPDecider_FailOpen: with FailOpen=true, a transport error
// returns allow.
func TestHTTPDecider_FailOpen(t *testing.T) {
	d, err := New(Config{URL: "http://127.0.0.1:1", FailOpen: true})
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
	if err != nil {
		t.Fatalf("Allow: %v", err)
	}
	if got != true {
		t.Errorf("on unreachable host with FailOpen, Allow = false, want true")
	}
}

// TestCachingDecider_AmortizesRoundTrips: a HTTPDecider wrapped in
// the default 1s cache only round-trips once for a burst of identical
// queries. Verifies the listing-amortization benefit for external mode.
func TestCachingDecider_AmortizesRoundTrips(t *testing.T) {
	var hits int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		hits++
		_, _ = w.Write([]byte(`{"result": true}`))
	}))
	defer srv.Close()

	d, err := New(Config{URL: srv.URL}) // CacheTTL=0 → default 1s
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	in := AllowInput{Path: "/p"}
	in.User.Email = "alice@example.com"

	// 50 identical calls → exactly 1 round-trip thanks to the cache.
	for i := 0; i < 50; i++ {
		got, err := d.Allow(context.Background(), in)
		if err != nil {
			t.Fatalf("Allow #%d: %v", i, err)
		}
		if !got {
			t.Errorf("Allow #%d = false, want true", i)
		}
	}
	if hits != 1 {
		t.Errorf("OPA round-trips = %d, want 1 (cache miss-and-fill)", hits)
	}
}

// TestCachingDecider_DifferentInputsSeparatelyKeyed: changing email or
// path produces a separate cache entry; a different-decision answer is
// not masked by the cached one.
func TestCachingDecider_DifferentInputsSeparatelyKeyed(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var wrap struct {
			Input AllowInput `json:"input"`
		}
		_ = json.NewDecoder(r.Body).Decode(&wrap)
		// Allow only "alice"; deny everyone else.
		allow := wrap.Input.User.Email == "alice@example.com"
		resp, _ := json.Marshal(map[string]bool{"result": allow})
		_, _ = w.Write(resp)
	}))
	defer srv.Close()

	d, err := New(Config{URL: srv.URL})
	if err != nil {
		t.Fatalf("New: %v", err)
	}

	for _, tc := range []struct {
		email string
		want  bool
	}{
		{"alice@example.com", true},
		{"bob@example.com", false},
		{"alice@example.com", true},  // cached
		{"bob@example.com", false},   // cached
		{"carol@example.com", false}, // new
	} {
		in := AllowInput{Path: "/p"}
		in.User.Email = tc.email
		got, err := d.Allow(context.Background(), in)
		if err != nil {
			t.Fatalf("Allow(%s): %v", tc.email, err)
		}
		if got != tc.want {
			t.Errorf("Allow(%s) = %v, want %v", tc.email, got, tc.want)
		}
	}
}

// TestCachingDecider_TTLExpires: a cached decision is re-fetched after
// the TTL window. Using a very short TTL (10ms) so the test runs fast.
func TestCachingDecider_TTLExpires(t *testing.T) {
	var hits int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		hits++
		_, _ = w.Write([]byte(`{"result": true}`))
	}))
	defer srv.Close()

	d, err := New(Config{URL: srv.URL, CacheTTL: 10 * 1000000}) // 10ms in ns
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	in := AllowInput{Path: "/p"}
	in.User.Email = "alice@example.com"

	if _, err := d.Allow(context.Background(), in); err != nil {
		t.Fatal(err)
	}
	if _, err := d.Allow(context.Background(), in); err != nil {
		t.Fatal(err)
	}
	// Two calls so far; the second is a cache hit. hits==1.
	if hits != 1 {
		t.Errorf("after 2 calls within TTL, hits=%d, want 1", hits)
	}
	// Wait past the TTL.
	time.Sleep(20 * 1000000) // 20ms
	if _, err := d.Allow(context.Background(), in); err != nil {
		t.Fatal(err)
	}
	if hits != 2 {
		t.Errorf("after TTL expiry, hits=%d, want 2 (re-fetched)", hits)
	}
}

// TestCachingDecider_NegativeTTLDisablesCache: CacheTTL<0 returns the
// inner decider unwrapped, useful for tests that want predictable
// per-call HTTP traffic.
func TestCachingDecider_NegativeTTLDisablesCache(t *testing.T) {
	var hits int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		hits++
		_, _ = w.Write([]byte(`{"result": true}`))
	}))
	defer srv.Close()

	d, err := New(Config{URL: srv.URL, CacheTTL: -1})
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	in := AllowInput{Path: "/p"}
	in.User.Email = "alice@example.com"
	for i := 0; i < 5; i++ {
		_, _ = d.Allow(context.Background(), in)
	}
	if hits != 5 {
		t.Errorf("with cache disabled, hits=%d, want 5 (one per Allow)", hits)
	}
}

// TestHTTPDecider_MalformedResponse: a 200 with a missing/garbage
// result field also fails closed.
func TestHTTPDecider_MalformedResponse(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"unexpected": "shape"}`))
	}))
	defer srv.Close()

	d, err := New(Config{URL: srv.URL})
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
	if err != nil {
		t.Fatalf("Allow: %v", err)
	}
	if got != false {
		t.Errorf("on missing result, Allow = true, want false (fail-closed)")
	}
}
