package auth

import (
	"context"
	"encoding/json"
	"errors"
	"path/filepath"
	"strings"
	"testing"

	"github.com/kami/maven/internal/ipc"
)

func TestMaxLayer_SurfaceCapsAuthority(t *testing.T) {
	// The invariant: surface caps maximum authority. A surface structurally
	// unable to carry passkey-user-verification caps below L3 — voice never
	// reaches step-up, telegram never reaches step-up, pc_client + authed_page
	// + the daemon's own process DO.
	cases := []struct {
		surface Surface
		max     Layer
	}{
		{SurfaceVoice, Layer0},
		{SurfaceTelegram, Layer2},
		{SurfacePCClient, Layer3},
		{SurfaceAuthedPage, Layer3},
		{SurfaceCoreProcess, Layer3},
		{SurfaceUnknown, -1},
	}
	for _, c := range cases {
		got := MaxLayer(c.surface)
		if got != c.max {
			t.Errorf("MaxLayer(%s) = %d; want %d (surface caps authority)", c.surface, got, c.max)
		}
	}
}

func TestSourceAllowed(t *testing.T) {
	cases := []struct {
		scope []string
		src   string
		want  bool
	}{
		{[]string{"*"}, "anything", true},
		{[]string{"poll:healthcheck"}, "poll:healthcheck", true},
		{[]string{"poll:healthcheck"}, "poll:uptime", false}, // compromised poller can't forge a trigger
		{[]string{"poll:healthcheck", "tap:water"}, "tap:water", true},
		{[]string{"poll:healthcheck", "tap:water"}, "ambient", false},
		{[]string{}, "anything", false}, // fail closed
		{nil, "anything", false},
	}
	for _, c := range cases {
		if got := SourceAllowed(c.scope, c.src); got != c.want {
			t.Errorf("SourceAllowed(%v, %q) = %v; want %v", c.scope, c.src, got, c.want)
		}
	}
}

func TestRequirement_Table(t *testing.T) {
	// WriteFact is AuthWrite (carries source-scope); EnableTool is AuthStepUp
	// (registration-enable moves the boundary); all others are AuthRead.
	if got := Requirement(ipc.MethodWriteFact); got != AuthWrite {
		t.Errorf("WriteFact authority = %v; want AuthWrite", got)
	}
	if got := Requirement(ipc.MethodEnableTool); got != AuthStepUp {
		t.Errorf("EnableTool authority = %v; want AuthStepUp", got)
	}
	reads := []ipc.Method{
		ipc.MethodLatestFact, ipc.MethodLatestFactBySource, ipc.MethodSince,
		ipc.MethodPresence, ipc.MethodRecentOutcomes,
		ipc.MethodCreateReminder,
		ipc.MethodListPendingReminders,
		ipc.MethodRecordNudge, ipc.MethodResolveNudge,
		ipc.MethodChat,
	}
	for _, m := range reads {
		if got := Requirement(m); got != AuthRead {
			t.Errorf("%s authority = %v; want AuthRead", m, got)
		}
	}
}

func TestGate_EnableTool_StepUp(t *testing.T) {
	// EnableTool is AuthStepUp. At the floor (FloorEnrollment L3 + FloorSession
	// asserting L3), the local caller may enable — the authed mavweb /tools page
	// hits this path. With a nil Session (no step-up asserted), it's refused —
	// step-up can't be granted from what wasn't demonstrated.
	ctx := context.Background()
	withSession := &Gate{Enrollment: NewFloorEnrollment(), Session: FloorSession{}}
	if err := withSession.Check(ctx, ipc.MethodEnableTool, nil); err != nil {
		t.Errorf("EnableTool with FloorSession = %v; want nil (floor permits)", err)
	}
	noSession := &Gate{Enrollment: NewFloorEnrollment()}
	if err := noSession.Check(ctx, ipc.MethodEnableTool, nil); !errors.Is(err, ipc.ErrForbidden) {
		t.Errorf("EnableTool with nil Session = %v; want ErrForbidden (fail closed)", err)
	}
}

func TestCan_Unenrolled_FailClosed(t *testing.T) {
	// No Module / unknown surface ⇒ refused, NOT "0-level authed". This is
	// the surface-caps property applied before the layer caps.
	if err := Can(ipc.MethodPresence, Scope{}, nil); !errors.Is(err, ErrUnenrolled) {
		t.Errorf("Can with empty Scope = %v; want ErrUnenrolled", err)
	}
	if err := Can(ipc.MethodPresence, Scope{Module: "x", Surface: SurfaceUnknown}, nil); !errors.Is(err, ErrUnenrolled) {
		t.Errorf("Can with unknown surface = %v; want ErrUnenrolled", err)
	}
}

func TestCan_WriteFact_SourceScope(t *testing.T) {
	// The spec's compromised-poller case: a poller enrolled to write
	// poll:healthcheck can't forge poll:uptime (or anything else).
	poller := Scope{
		Surface:     SurfaceCoreProcess,
		Module:      "poller:healthcheck",
		SourceScope: []string{"poll:healthcheck"},
	}
	core := Scope{
		Surface:     SurfaceCoreProcess,
		Module:      "core",
		SourceScope: []string{"*"},
	}

	// In-scope write succeeds.
	if err := Can(ipc.MethodWriteFact, poller, mustWriteFactParams("poll:healthcheck")); err != nil {
		t.Errorf("poller writing poll:healthcheck = %v; want nil", err)
	}
	// Out-of-scope write is forbidden — auth.ErrForbidden in the chain.
	err := Can(ipc.MethodWriteFact, poller, mustWriteFactParams("poll:uptime"))
	if err == nil || !errors.Is(err, ErrForbidden) {
		t.Errorf("poller writing poll:uptime = %v; want ErrForbidden in chain", err)
	}
	if err != nil && !strings.Contains(err.Error(), "poll:uptime") {
		t.Errorf("forbidden err should name the offending source: got %q", err)
	}

	// Core (wildcard) writes anything.
	if err := Can(ipc.MethodWriteFact, core, mustWriteFactParams("poll:uptime")); err != nil {
		t.Errorf("core writing poll:uptime = %v; want nil (wildcard scope)", err)
	}

	// Empty SourceScope ⇒ fail closed even for an enrolled module.
	empty := Scope{Surface: SurfaceCoreProcess, Module: "x", SourceScope: nil}
	if err := Can(ipc.MethodWriteFact, empty, mustWriteFactParams("poll:healthcheck")); !errors.Is(err, ErrForbidden) {
		t.Errorf("empty SourceScope WriteFact = %v; want ErrForbidden", err)
	}
}

func TestCan_Reads_AnyEnrolledModule(t *testing.T) {
	// Reads permit any enrolled module (the enrollment already gated caller
	// identity). Reads on SurfaceVoice are OK too — enrollment may have
	// enrolled a voice module for read-only purposes (e.g. ambient parse).
	for _, surf := range []Surface{SurfaceVoice, SurfaceTelegram, SurfacePCClient, SurfaceCoreProcess} {
		scope := Scope{Surface: surf, Module: "x", SourceScope: []string{"*"}}
		if err := Can(ipc.MethodPresence, scope, nil); err != nil {
			t.Errorf("read on %s = %v; want nil (any enrolled module may read)", surf, err)
		}
	}
}

// --- Gate ---

func TestGate_FloorEnrollment_PreservesPreAuth(t *testing.T) {
	// The floor must be a no-op vs pre-auth: same-uid trusted, full source
	// scope, every read and in-scope write passes. Tied end-to-end through
	// Gate.Check on the ipc.Method enumeration, so AuthRead / AuthWrite both
	// flow through the composition.
	g := &Gate{Enrollment: NewFloorEnrollment()}
	ctx := context.Background()
	for _, m := range []ipc.Method{
		ipc.MethodPresence, ipc.MethodSince, ipc.MethodRecentOutcomes,
		ipc.MethodCreateReminder, ipc.MethodRecordNudge,
		ipc.MethodChat,
	} {
		if err := g.Check(ctx, m, nil); err != nil {
			t.Errorf("floor gate Check(%s) = %v; want nil (pre-auth preserved)", m, err)
		}
	}
	if err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime")); err != nil {
		t.Errorf("floor gate Check(WriteFact, *) = %v; want nil (full source scope)", err)
	}
}

func TestGate_StaticEnrollment_SourceScopeEnforced(t *testing.T) {
	// A poller enrolled to write only poll:healthcheck is denied a write to
	// poll:uptime. Gate.Authoritative on real source-scope. Wire code should
	// be forbidden once we wire through the ipc layer (next test exercises
	// that path through a real socket).
	pollerUid := int32(70001)
	enrollment := &StaticEnrollment{
		ByUid: map[int32]Scope{
			pollerUid: {
				Surface:     SurfaceCoreProcess,
				Module:      "poll:healthcheck",
				SourceScope: []string{"poll:healthcheck"},
			},
		},
		Default: &Scope{ // any other uid gets full trust — for tests, doesn't matter
			Surface:     SurfaceCoreProcess,
			Module:      "core",
			SourceScope: []string{"*"},
		},
	}
	g := &Gate{Enrollment: enrollment}

	ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: pollerUid, Pid: 1234})
	if err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:healthcheck")); err != nil {
		t.Errorf("poller in-scope write = %v; want nil", err)
	}
	err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime"))
	if err == nil {
		t.Fatalf("poller out-of-scope write returned nil; want forbidden")
	}
	if !errors.Is(err, ipc.ErrForbidden) {
		t.Errorf("poller out-of-scope err chain = %v; want ipc.ErrForbidden in chain (wire code wraps ipc.ErrForbidden)", err)
	}
	if !errors.Is(err, ErrForbidden) {
		t.Errorf("poller out-of-scope err chain = %v; want auth.ErrForbidden in chain (auth tests can satisfy Is)", err)
	}
}

func TestGate_Unenrolled_FailsClosed(t *testing.T) {
	enrollment := &StaticEnrollment{} // no Default ⇒ ErrUnenrolled for any caller
	g := &Gate{Enrollment: enrollment}
	ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: 4242, Pid: 99})
	err := g.Check(ctx, ipc.MethodPresence, nil)
	if err == nil {
		t.Fatalf("un-enrolled caller returned nil; want forbidden")
	}
	if !errors.Is(err, ipc.ErrForbidden) {
		t.Errorf("unenrolled err = %v; want ipc.ErrForbidden in chain (mapped at wire edge)", err)
	}
}

// --- end-to-end through ipc.Server ---

// TestGate_IpcServer_WiresCheck end-to-end. Server.Check = g.Check; an
// out-of-scope WriteFact sent through a real socket rehydrates as
// ipc.ErrForbidden on the wire. The seam — no CoreAPI change, no module
// change — is the whole point: the daemon sets Server.Check at construction.
func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) {
	pollerUid := int32(70001)
	enrollment := &StaticEnrollment{
		ByUid: map[int32]Scope{
			pollerUid: {
				Surface:     SurfaceCoreProcess,
				Module:      "poll:healthcheck",
				SourceScope: []string{"poll:healthcheck"},
			},
		},
		Default: &Scope{ // any other uid ⇒ un-enrolled → forbidden
			Surface: SurfaceUnknown,
			Module:  "",
		},
	}
	gate := &Gate{Enrollment: enrollment}

	dir := t.TempDir()
	sock := filepath.Join(dir, "maven.sock")
	// A fake CoreAPI that records writes; the auth verdict should fire before
	// it ever gets called.
	fake := &recordingAPI{}
	srv, err := ipc.Listen(sock, fake)
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	srv.Check = gate.Check
	done := make(chan struct{})
	go func() {
		_ = srv.Serve()
		close(done)
	}()
	t.Cleanup(func() {
		_ = srv.Close()
		<-done
	})

	// Direct in-process check: we can caller-stamp our test ctx with any
	// uid/pid we want. The socket path produces our real uid (via
	// SO_PEERCRED), but the auth layer's verdict depends only on the
	// caller-shape the wire delivered, not the wire transport — so the
	// in-process check uses the same Gate.Check the socketed dispatch calls.
	ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: pollerUid, Pid: 1})
	if err := gate.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:healthcheck")); err != nil {
		t.Errorf("in-scope write through gate = %v; want nil", err)
	}
	err = gate.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime"))
	if !errors.Is(err, ipc.ErrForbidden) {
		t.Errorf("out-of-scope write through gate = %v; want ipc.ErrForbidden", err)
	}

	// Smoke the actual wire path: our real uid is un-enrolled per the
	// StaticEnrollment (only 70001 is enrolled), so the very next call we
	// make over the socket is forbidden at the wire — rehydrating on the
	// client side as ipc.ErrForbidden. This is the full chain:
	// dispatch → Check (auth) → codeOf → wire → hydrate.
	cli, err := ipc.Dial(sock)
	if err != nil {
		t.Fatalf("dial: %v", err)
	}
	t.Cleanup(func() { _ = cli.Close() })
	_, err = cli.WriteFact(context.Background(), ipc.WriteFactReq{
		Kind:       "env",
		Key:        "service_down",
		Value:      "down",
		Source:     "poll:healthcheck",
		Confidence: 1.0,
	})
	if !errors.Is(err, ipc.ErrForbidden) {
		t.Errorf("wire: write from real uid (unenrolled per StaticEnrollment) = %v; want ipc.ErrForbidden", err)
	}
	if fake.writes != 0 {
		t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes)
	}
	_, err = cli.Chat(context.Background(), "web", "привет")
	if !errors.Is(err, ipc.ErrForbidden) {
		t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err)
	}
	if fake.chats != 0 {
		t.Errorf("auth refused chat but CoreAPI.Chat was called %d time(s)", fake.chats)
	}
}

func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) {
	gate := &Gate{Enrollment: NewFloorEnrollment()}
	fake := &recordingAPI{}
	sock := filepath.Join(t.TempDir(), "maven.sock")
	srv, err := ipc.Listen(sock, fake)
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	srv.Check = gate.Check
	done := make(chan struct{})
	go func() {
		_ = srv.Serve()
		close(done)
	}()
	t.Cleanup(func() {
		_ = srv.Close()
		<-done
	})

	cli, err := ipc.Dial(sock)
	if err != nil {
		t.Fatalf("dial: %v", err)
	}
	t.Cleanup(func() { _ = cli.Close() })
	reply, err := cli.Chat(context.Background(), "web", "привет")
	if err != nil {
		t.Fatalf("Chat: %v", err)
	}
	if reply.Reply != "echo: привет" {
		t.Fatalf("Chat reply = %q; want %q", reply.Reply, "echo: привет")
	}
	if fake.chats != 1 {
		t.Fatalf("CoreAPI.Chat calls = %d; want 1", fake.chats)
	}
}

// recordingAPI — a no-op CoreAPI that counts WriteFact invocations; the auth
// check must reject before reaching it, otherwise the refusal leaks into the
// fake's counts and we fail. Embeds ipc.UnimplementedCoreAPI so every method
// this test doesn't exercise returns ipc.ErrNotImplemented loudly instead of
// being hand-stubbed to a canned value nobody checks.
type recordingAPI struct {
	ipc.UnimplementedCoreAPI

	writes int
	chats  int
}

func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64, error) {
	r.writes++
	return int64(r.writes), nil
}

func (r *recordingAPI) Chat(_ context.Context, _, text string) (ipc.ChatReply, error) {
	r.chats++
	return ipc.ChatReply{Reply: "echo: " + text}, nil
}

// mustWriteFactParams — minimal WriteFactReq JSON with only the source field,
// matching what ipc.dispatch hands to Server.Check (the raw params frame).
func mustWriteFactParams(source string) []byte {
	b, err := json.Marshal(ipc.WriteFactReq{
		Kind:       "env",
		Key:        "service_down",
		Value:      "down",
		Source:     source,
		Confidence: 1.0,
	})
	if err != nil {
		panic(err)
	}
	return b
}

// TestRequirement_SwapModel — loading a different resident model is an owner
// action at the same rung as mutating the tool allowlist: it decides how every
// utterance is routed and how every reply is worded. The read side is not.
func TestRequirement_SwapModel(t *testing.T) {
	if got := Requirement(ipc.MethodSwapModel); got != AuthStepUp {
		t.Errorf("SwapModel authority = %v; want AuthStepUp", got)
	}
	if got := Requirement(ipc.MethodModelStatus); got != AuthRead {
		t.Errorf("ModelStatus authority = %v; want AuthRead", got)
	}
	// A surface that cannot carry a passkey gesture cannot swap the model, no
	// matter what it is enrolled as — this is the "never through voice" property.
	voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
	if err := Can(ipc.MethodSwapModel, voice, nil); !errors.Is(err, ErrForbidden) {
		t.Errorf("voice swapping the model = %v; want ErrForbidden", err)
	}
	// And with no step-up session asserted, the gate refuses even a capable surface.
	noSession := &Gate{Enrollment: NewFloorEnrollment()}
	if err := noSession.Check(context.Background(), ipc.MethodSwapModel, nil); !errors.Is(err, ipc.ErrForbidden) {
		t.Errorf("SwapModel with no asserted step-up = %v; want ErrForbidden", err)
	}
}

// TestRequirement_ListMutation — the three methods that change what is on his
// lists sit on one rung. IngestMail joined them on 2026-08-01; it used to be
// AuthRead, which made it disagree with SetTaskStatus about the same question.
// CaptureTask stays a read: it puts one line on a list he asked for.
func TestRequirement_ListMutation(t *testing.T) {
	for _, m := range []ipc.Method{
		ipc.MethodIngestMail, ipc.MethodSetTaskStatus,
		ipc.MethodMarkReminder, ipc.MethodCancelReminder,
	} {
		if got := Requirement(m); got != AuthWrite {
			t.Errorf("%s authority = %v; want AuthWrite", m, got)
		}
	}
	if got := Requirement(ipc.MethodCaptureTask); got != AuthRead {
		t.Errorf("CaptureTask authority = %v; want AuthRead", got)
	}
	// mavmaild keeps working: AuthWrite outside WriteFact only needs enrollment.
	maild := Scope{Surface: SurfaceCoreProcess, Module: "mavmaild", SourceScope: []string{"mail:*"}}
	if err := Can(ipc.MethodIngestMail, maild, nil); err != nil {
		t.Errorf("mavmaild ingesting mail = %v; want allowed", err)
	}
}

// TestRequirement_Capture — recording other people is a write, not a read: it
// puts audio of them on disk. The read side, "что ты записываешь?", is not.
//
// It is deliberately NOT AuthStepUp. Step-up needs a passkey gesture, which the
// voice path cannot make, so putting it there would mean "запиши встречу" could
// never work by voice. The real gate on this capability is that the methods do
// not exist at all unless the operator enabled a capture block.
func TestRequirement_Capture(t *testing.T) {
	for _, m := range []ipc.Method{
		ipc.MethodCaptureStart, ipc.MethodCaptureAppend, ipc.MethodCaptureStop,
	} {
		if got := Requirement(m); got != AuthWrite {
			t.Errorf("%s authority = %v; want AuthWrite", m, got)
		}
	}
	if got := Requirement(ipc.MethodCaptureStatus); got != AuthRead {
		t.Errorf("CaptureStatus authority = %v; want AuthRead", got)
	}
	// Voice can start one: it is the surface he will actually use to say
	// "запиши встречу", and it carries AuthWrite.
	voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
	if err := Can(ipc.MethodCaptureStart, voice, nil); err != nil {
		t.Errorf("voice starting a capture = %v; want allowed", err)
	}
}

// TestRequirement_Speaker — a voiceprint is a biometric of a named person, so
// taking one is step-up: a deliberate act from a surface that can carry a
// passkey gesture, never something the voice path does mid-conversation.
//
// Deletion is one rung lower, and that asymmetry is the point. Everywhere else
// in the table the destructive direction is gated at least as hard as the
// constructive one; for a biometric that would be backwards, because getting
// rid of it must never be the harder half.
func TestRequirement_Speaker(t *testing.T) {
	if got := Requirement(ipc.MethodEnrollSpeaker); got != AuthStepUp {
		t.Errorf("EnrollSpeaker authority = %v; want AuthStepUp", got)
	}
	if got := Requirement(ipc.MethodForgetSpeaker); got != AuthWrite {
		t.Errorf("ForgetSpeaker authority = %v; want AuthWrite", got)
	}
	if got := Requirement(ipc.MethodListSpeakers); got != AuthRead {
		t.Errorf("ListSpeakers authority = %v; want AuthRead", got)
	}
	// Voice cannot enrol anybody, however the utterance is phrased.
	voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
	if err := Can(ipc.MethodEnrollSpeaker, voice, nil); err == nil {
		t.Error("voice enrolling a speaker was allowed; want refused")
	}
	// But it can read the roster, which is what answering "кого ты знаешь?"
	// needs.
	if err := Can(ipc.MethodListSpeakers, voice, nil); err != nil {
		t.Errorf("voice listing speakers = %v; want allowed", err)
	}
}

// Describing an image is a read. Saving the description is a write of recall
// corpus under a source no enrollment owns, so it is held to the same
// source-scope rule WriteFact is. Before this, any AuthRead caller could put a
// small VLM's guess into what Maven knows.
func TestCan_DescribeImage_SaveNoteNeedsScope(t *testing.T) {
	poller := Scope{Surface: SurfaceTelegram, Module: "poll", SourceScope: []string{"poll:healthcheck"}}
	web := Scope{Surface: SurfaceAuthedPage, Module: "web", SourceScope: []string{"*"}}

	plain, err := json.Marshal(ipc.DescribeImageReq{Data: []byte("x")})
	if err != nil {
		t.Fatal(err)
	}
	noting, err := json.Marshal(ipc.DescribeImageReq{Data: []byte("x"), SaveNote: true})
	if err != nil {
		t.Fatal(err)
	}
	if err := Can(ipc.MethodDescribeImage, poller, plain); err != nil {
		t.Errorf("describing without saving must stay a read: %v", err)
	}
	if err := Can(ipc.MethodDescribeImage, poller, noting); !errors.Is(err, ErrForbidden) {
		t.Errorf("save_note out of scope = %v, want ErrForbidden", err)
	}
	if err := Can(ipc.MethodDescribeImage, web, noting); err != nil {
		t.Errorf("a module scoped to everything must still be allowed: %v", err)
	}
}
