package authz

import (
	"errors"
	"os"
	"path/filepath"
	"testing"
	"time"

	"golang.org/x/crypto/ssh"
)

// fixed reference instant used across in-Go tests (deterministic time window).
var refNow = time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)

func atRefNow(v *Verifier) *Verifier { v.now = func() time.Time { return refNow }; return v }

// rejects asserts a Verify error matches the expected sentinel.
func rejects(t *testing.T, err, want error) {
	t.Helper()
	if !errors.Is(err, want) {
		t.Fatalf("want %v, got %v", want, err)
	}
}

// signerSet builds a one-key operational allow-list around an ssh.PublicKey.
func signerSet(pub ssh.PublicKey, keyID string) []AllowedSigner {
	return []AllowedSigner{{KeyID: keyID, Role: RoleOperational, PublicKey: pub}}
}

// validBlob is an op blob valid at refNow.
func validBlob(host, guest, keyID, nonce string) []byte {
	return canonicalBlob("guest_destroy", host, guest, keyID, nonce, `{"purge":true}`,
		refNow.Add(-time.Hour), refNow.Add(time.Hour))
}

// --- Real OpenSSH interop: committed ssh-keygen fixture ---

func TestVerify_RealSSHKeygenFixture(t *testing.T) {
	blob := readFile(t, "testdata/op_blob.json")
	sig := readFile(t, "testdata/op_blob.sig")
	pubLine := readFile(t, "testdata/operator.pub")

	signer, err := NewAllowedSigner("felhom-op-1", RoleOperational, string(pubLine))
	if err != nil {
		t.Fatalf("NewAllowedSigner: %v", err)
	}
	v := New([]AllowedSigner{signer}, NewMemoryNonceStore(), "demo-felhom")
	v.now = func() time.Time { return time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) } // inside fixture window

	op, err := v.Verify(blob, sig)
	if err != nil {
		t.Fatalf("real fixture did not verify: %v", err)
	}
	if op.Op != "guest_destroy" || op.HostID != "demo-felhom" || op.GuestID != "9001" {
		t.Errorf("unexpected op: %+v", op)
	}
	if op.KeyID != "felhom-op-1" || !op.KeyIDMatchesSigner {
		t.Errorf("key_id audit wrong: %q matches=%v", op.KeyID, op.KeyIDMatchesSigner)
	}
}

// --- Happy path (in-Go ed25519) ---

func TestVerify_HappyPath(t *testing.T) {
	pub, sign := newEd25519Signer(t)
	blob := validBlob("demo-felhom", "9001", "op", "n-happy-0001")
	sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)

	v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
	op, err := v.Verify(blob, sig)
	if err != nil {
		t.Fatalf("Verify: %v", err)
	}
	if op.Op != "guest_destroy" || op.Signer.KeyID != "op" {
		t.Errorf("op = %+v", op)
	}
}

// --- Per-stage rejection, each with an otherwise-valid signature ---

func TestVerify_RejectsPerStage(t *testing.T) {
	pub, sign := newEd25519Signer(t)
	other, _ := newEd25519Signer(t)

	t.Run("wrong namespace", func(t *testing.T) {
		blob := validBlob("demo-felhom", "9001", "op", "n-ns-1")
		sig := mintArmor(t, pub.Marshal(), "felhom-op-wrong", "sha512", blob, sign)
		v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
		_, err := v.Verify(blob, sig)
		rejects(t, err, ErrNamespace)
	})

	t.Run("signer not in set", func(t *testing.T) {
		blob := validBlob("demo-felhom", "9001", "op", "n-unk-1")
		sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
		v := atRefNow(New(signerSet(other, "other"), NewMemoryNonceStore(), "demo-felhom"))
		_, err := v.Verify(blob, sig)
		rejects(t, err, ErrUnknownSigner)
	})

	t.Run("tampered blob (crypto)", func(t *testing.T) {
		blob := validBlob("demo-felhom", "9001", "op", "n-tamper-1")
		sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
		tampered := append([]byte{}, blob...)
		tampered[len(tampered)-2] = '!' // mutate inside the JSON
		v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
		_, err := v.Verify(tampered, sig)
		rejects(t, err, ErrBadSignature)
	})

	t.Run("retargeted host", func(t *testing.T) {
		blob := validBlob("other-host", "9001", "op", "n-target-1")
		sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
		v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
		_, err := v.Verify(blob, sig)
		rejects(t, err, ErrTarget)
	})

	t.Run("expired", func(t *testing.T) {
		blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-exp-1", "{}",
			refNow.Add(-2*time.Hour), refNow.Add(-time.Hour))
		sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
		v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
		_, err := v.Verify(blob, sig)
		rejects(t, err, ErrExpired)
	})

	t.Run("not yet valid", func(t *testing.T) {
		blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-nyv-1", "{}",
			refNow.Add(time.Hour), refNow.Add(2*time.Hour))
		sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
		v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
		_, err := v.Verify(blob, sig)
		rejects(t, err, ErrNotYetValid)
	})

	t.Run("replay", func(t *testing.T) {
		blob := validBlob("demo-felhom", "9001", "op", "n-replay-1")
		sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
		v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
		if _, err := v.Verify(blob, sig); err != nil {
			t.Fatalf("first use: %v", err)
		}
		_, err := v.Verify(blob, sig)
		rejects(t, err, ErrReplay)
	})
}

// --- THE anti-replay invariant: an invalid-sig attempt must NOT burn the nonce ---

func TestVerify_InvalidSigDoesNotBurnNonce(t *testing.T) {
	pub, sign := newEd25519Signer(t)
	store := NewMemoryNonceStore()
	const nonce = "n-not-burned-cafe"

	blobV := validBlob("demo-felhom", "9001", "op", nonce)
	validSig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blobV, sign)

	// Attacker reuses the SAME nonce but a signature that fails crypto (valid key,
	// signed over different bytes) — passes namespace + allow-list, fails at the
	// crypto stage, which is BEFORE the nonce stage.
	badSig := mintArmor(t, pub.Marshal(), Namespace, "sha512", []byte(`{"different":"bytes"}`), sign)

	v := atRefNow(New(signerSet(pub, "op"), store, "demo-felhom"))
	if _, err := v.Verify(blobV, badSig); !errors.Is(err, ErrBadSignature) {
		t.Fatalf("invalid attempt: want ErrBadSignature, got %v", err)
	}
	// The genuine valid op with the same nonce must still succeed — proving the
	// failed attempt did NOT burn the nonce (nonce-recorded-last).
	if _, err := v.Verify(blobV, validSig); err != nil {
		t.Fatalf("valid op after invalid attempt should succeed, got %v", err)
	}
}

// --- Persistence across restart (durable nonce store) ---

func TestVerify_ReplayRejectedAcrossRestart(t *testing.T) {
	pub, sign := newEd25519Signer(t)
	blob := validBlob("demo-felhom", "9001", "op", "n-persist-1")
	sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
	path := filepath.Join(t.TempDir(), "nonces.log")

	store1, err := OpenFileNonceStore(path)
	if err != nil {
		t.Fatal(err)
	}
	v1 := atRefNow(New(signerSet(pub, "op"), store1, "demo-felhom"))
	if _, err := v1.Verify(blob, sig); err != nil {
		t.Fatalf("first use: %v", err)
	}
	if err := store1.Close(); err != nil {
		t.Fatal(err)
	}

	// Fresh store + verifier over the SAME path — simulates an agent restart.
	store2, err := OpenFileNonceStore(path)
	if err != nil {
		t.Fatal(err)
	}
	defer store2.Close()
	v2 := atRefNow(New(signerSet(pub, "op"), store2, "demo-felhom"))
	_, err = v2.Verify(blob, sig)
	rejects(t, err, ErrReplay)
}

// --- Key-type-agnostic: synthetic FIDO2 sk-ssh-ed25519 through the unchanged path ---

func TestVerify_KeyTypeAgnostic_SK(t *testing.T) {
	skPub, skSign := newSyntheticSKSigner(t)
	blob := validBlob("demo-felhom", "9001", "op", "n-sk-1")
	sig := mintArmor(t, skPub.Marshal(), Namespace, "sha512", blob, skSign)

	v := atRefNow(New(signerSet(skPub, "op"), NewMemoryNonceStore(), "demo-felhom"))
	op, err := v.Verify(blob, sig)
	if err != nil {
		t.Fatalf("sk verify through unchanged path failed: %v", err)
	}
	if op.Op != "guest_destroy" {
		t.Errorf("op = %q", op.Op)
	}
}

// --- Byte-exactness: a re-serialized blob is NOT re-canonicalized (fails crypto) ---

func TestVerify_ByteExactNoRecanonicalization(t *testing.T) {
	pub, sign := newEd25519Signer(t)
	blob := validBlob("demo-felhom", "9001", "op", "n-bytes-1")
	sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)

	// Same fields, different whitespace + key order — what a non-identical producer
	// canonicalizer would emit. The verifier verifies raw bytes, so this fails crypto.
	reserialized := []byte(`{ "op":"guest_destroy", "target":{"host_id":"demo-felhom","guest_id":"9001"}, "params":{"purge":true}, "nonce":"n-bytes-1", "issued_at":"` +
		refNow.Add(-time.Hour).Format(time.RFC3339) + `", "expires_at":"` + refNow.Add(time.Hour).Format(time.RFC3339) + `", "key_id":"op" }`)

	v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
	_, err := v.Verify(reserialized, sig)
	rejects(t, err, ErrBadSignature)
}

func readFile(t *testing.T, path string) []byte {
	t.Helper()
	b, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	return b
}
