package auth

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"

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

// Gate — wraps Enrollment + optional Session state and exposes a CheckFunc
// the daemon wires into ipc.Server.Check. The ONE place a wire call gets
// authorized. Adding the auth layer does not change CoreAPI, dispatch, or
// module code; the daemon constructs a Gate and sets Server.Check = gate.Check.
//
// floors:
//   - FloorEnrollment ⇒ no change from pre-auth behavior (same-uid trusted,
//     full source scope).
//   - nil Session ⇒ step-up never asserted; any AuthStepUp call refused
//     (surface caps + session caps agree to fail closed). Today's CoreAPI has
//     no AuthStepUp methods, so nil Session is the daemon floor.
type Gate struct {
	Enrollment Enrollment
	Session    Session // nil ⇒ step-up not asserted
}

// Session — the per-session step-up state. The impure seam the passkey
// verifier implements: Assert records a successful user-verification gesture,
// CurrentLayer returns how high the session is asserted right now (cold boot ⇒
// not-asserted; passkey challenge ⇒ L3 for the session lifetime). Without a
// Session wired, step-up-requiring calls (EnableTool, cold-start unlock) fail
// closed — the gate can't grant what wasn't demonstrated.
//
// Today the daemon runs no interface that asserts session step-up (no pc client
// yet); the floor is nil Session ⇒ AuthStepUp always refused. The shape is
// here so the passkey verifier is a single new impl, not a dispatch change.
type Session interface {
	// CurrentLayer returns the authority the session currently carries.
	// Outside a step-up window, returns the layer the surface can carry on its
	// own minus the step-up contribution (e.g. a pc_client without asserted
	// step-up returns Layer2; the passkey gesture bumps it to Layer3 for the
	// session lifetime).
	CurrentLayer(ctx context.Context, scope Scope) Layer

	// Assert — record a successful step-up gesture for scope. The passkey
	// verifier returns nil and the daemon-queried Session treats this session
	// as L3 until it expires. Floor impls may return ErrStepUpUnsupported.
	Assert(ctx context.Context, scope Scope) error
}

// ErrStepUpUnsupported — returned by floor Session.Assert when no passkey
// verifier is wired. Distinct from ErrForbidden: a missing impl is a wiring
// bug, not a denial.
var ErrStepUpUnsupported = errors.New("auth: step-up not supported by this session")

// FloorSession — the step-up floor, mirroring FloorEnrollment: any caller that
// cleared the 0600 socket is trusted as fully step-asserted (L3). It exists so
// the floor is CONSISTENT — FloorEnrollment already grants same-uid callers L3
// for writes; without a matching Session floor, AuthStepUp methods (EnableTool)
// would be refused for the same callers, an accidental asymmetry. The real
// passkey verifier replaces this (a single new Session impl, no dispatch change)
// so step-up becomes a real gesture instead of a floor grant.
type FloorSession struct{}

// CurrentLayer — the floor trusts the local caller fully.
func (FloorSession) CurrentLayer(_ context.Context, _ Scope) Layer { return Layer3 }

// Assert — a no-op success at the floor (the caller is already trusted).
func (FloorSession) Assert(_ context.Context, _ Scope) error { return nil }

// Check — the authorization hook. Wired into ipc.Server.Check (single
// insertion point). Shape: nil ipc.Caller ⇒ in-process path (Lookup with
// hasCaller=false). Otherwise resolve via Enrollment; refuse on any error;
// run pure Can on the resolved scope + raw params.
//
// Returns ErrForbidden (mirrored to codeForbidden on the wire) for any
// authority failure; bubbles other errors (Enrollment I/O, ErrUnenrolled)
// up to dispatch where they're mapped to codeInternal or codeForbidden
// depending on identity-ness. We map ErrUnenrolled → forbidden: an unknown
// caller is not surfaced as "internal error" to a module.
func (g *Gate) Check(ctx context.Context, m ipc.Method, params json.RawMessage) error {
	caller, hasCaller := ipc.CallerFrom(ctx)
	scope, err := g.Enrollment.Lookup(ctx, caller, hasCaller)
	if err != nil {
		// Unenrolled ⇒ forbidden on the wire (codeForbidden). I/O failures of
		// the enrollment table are not "the caller is unauthorized"; they
		// bubble as internal via codeOf's default. Wrap with both sentinels
		// (Go 1.20+ multi-%w) so:
		//   - ipc.codeOf resolves to codeForbidden via errors.Is(ipc.ErrForbidden)
		//   - auth-package tests resolve via errors.Is(auth.ErrForbidden)
		//   - the daemon log carries both names.
		if errors.Is(err, ErrUnenrolled) {
			return fmt.Errorf("%w: %w", ipc.ErrForbidden, err)
		}
		return err
	}
	if err := Can(m, scope, params); err != nil {
		// Can already uses auth.ErrForbidden / ErrUnenrolled inside; we wrap
		// with ipc.ErrForbidden so codeOf resolves to codeForbidden at the
		// wire. The auth sentinel stays in the chain via %w (not %v) so
		// errors.Is(auth.ErrForbidden) works in auth-package tests.
		if errors.Is(err, ErrUnenrolled) || errors.Is(err, ErrForbidden) {
			return fmt.Errorf("%w: %w", ipc.ErrForbidden, err)
		}
		return err
	}
	// AuthStepUp verdict's surface-cap half is in Can. The session-level
	// check (was step-up actually asserted THIS session?) lives here so the
	// Session owns its own state; Can stays pure-data.
	if Requirement(m) == AuthStepUp {
		if g.Session == nil {
			return fmt.Errorf("%w: %w: AuthStepUp but no Session wired", ipc.ErrForbidden, ErrForbidden)
		}
		if g.Session.CurrentLayer(ctx, scope) < Layer3 {
			return fmt.Errorf("%w: %w: step-up not asserted this session", ipc.ErrForbidden, ErrForbidden)
		}
	}
	return nil
}
