package auth

import (
	"context"
	"errors"
	"fmt"

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

// Sentinel errors. The wire carries ErrForbidden as codeForbidden; the others
// (ErrUnenrolled distinct) floor to forbidden on the wire too — but the daemon
// log can still see the distinction server-side.
var (
	// ErrForbidden — the caller's scope caps the requested authority (a
	// voice-channel caller asking for L3, a poller writing an out-of-scope
	// source, etc.). The wire shape: codeForbidden.
	ErrForbidden = errors.New("auth: forbidden")

	// ErrUnenrolled — the caller isn't recognized by the enrollment table at
	// all. A distinct sentinel so the daemon can surface "this module's
	// enrollment is missing" as a wiring bug, not a generic denied.
	ErrUnenrolled = errors.New("auth: caller not enrolled")
)

// Enrollment — the impure seam mapping a connecting process to a Scope. The
// floor below trusts same-uid local callers fully (the 0600-floor equivalent),
// production wires the real enrollment table reading a Config / sqlite table.
//
// Lookup is the single impure call per dispatch; Can and Check downstream are
// pure. That keeps "no module ever reads its own authority" checkable:
// anywhere outside the Enrollment impl doing caller→scope resolution is a bug.
type Enrollment interface {
	// Lookup resolves the caller's Scope. Return ErrUnenrolled when the caller
	// isn't recognized; ErrForbidden when recognized but refused for policy
	// reasons (e.g. disabled module); any other error for I/O failure.
	// hasCaller=false is the in-process path (no ipc.Caller attached to ctx).
	Lookup(ctx context.Context, c ipc.Caller, hasCaller bool) (Scope, error)
}

// FloorEnrollment — today's auth floor. Same as the socket's 0600 perms: any
// same-uid caller is trusted as a "core" module (SurfaceCoreProcess, L3,
// write-any-source). The in-process path (no Caller) is the same: it's the
// daemon itself, holding the unlocked store, so it gets L3 trivially.
//
// This is the AUTH FLOOR, not the auth model — the spec's invariant (surface
// caps authority, source-scope, step-up) is shaped in policy.go and exercised
// in tests through tighter enrollments. The daemon swaps this out when the
// real enrollment table lands; nothing downstream changes.
type FloorEnrollment struct {
	// Module is the label FloorEnrollment stamps on every caller (default
	// "core"). Real enrollment derives this from Caller.Uid/Pid.
	Module string
}

// NewFloorEnrollment — default "core" module, full source scope, L3 cap.
// This preserves the prior (pre-auth) behavior: any same-uid caller was
// permitted everything. Compiles to identity authority.
func NewFloorEnrollment() *FloorEnrollment { return &FloorEnrollment{Module: "core"} }

// Lookup — same-uid floor. HasCaller=false ⇒ in-process path (trusted "core");
// HasCaller=true ⇒ for now we still trust (only same-uid can connect via the
// 0600 socket perms). The real enrollment table replaces this with a lookup
// keyed on Uid/Pid → Module entry.
func (f *FloorEnrollment) Lookup(_ context.Context, _ ipc.Caller, _ bool) (Scope, error) {
	return Scope{
		Surface:     SurfaceCoreProcess,
		Module:      f.Module,
		SourceScope: []string{"*"},
	}, nil
}

// StaticEnrollment — a hand-built enrollment for tests and demos: map every
// caller exact-match on Uid to a fixed Scope. Pure-ish (no I/O); the daemon
// holds it and lets the operator append at runtime; tests build their own.
// Used to model "a poller that can only write poll:healthcheck" — the spec's
// compromised-poller scenario — without standing up the full enrollment table.
type StaticEnrollment struct {
	// ByUid — uid-keyed scope. Mutated to add an enrolled module.
	ByUid map[int32]Scope

	// InProcess is the scope returned for in-process (HasCaller=false) calls.
	// nil ⇒ falls through to Default.
	InProcess *Scope

	// Default is returned when no specific entry matches. nil ⇒ ErrUnenrolled
	// (fail closed).
	Default *Scope
}

// Lookup walks the static map. hasCaller ⇒ ByUid → Default → ErrUnenrolled;
// in-process ⇒ InProcess → Default → ErrUnenrolled. Fail closed everywhere,
// because a StaticEnrollment is built deliberately and any unmatched caller
// is exactly the "who is this?" case the real table answers.
func (s *StaticEnrollment) Lookup(_ context.Context, c ipc.Caller, hasCaller bool) (Scope, error) {
	if !hasCaller {
		if s.InProcess != nil {
			return *s.InProcess, nil
		}
		if s.Default != nil {
			return *s.Default, nil
		}
		return Scope{}, ErrUnenrolled
	}
	if sc, ok := s.ByUid[c.Uid]; ok {
		return sc, nil
	}
	if s.Default != nil {
		return *s.Default, nil
	}
	return Scope{}, fmt.Errorf("%w (uid=%d)", ErrUnenrolled, c.Uid)
}
