package auth

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

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

// Authority — a discrete authority requirement per ipc.Method. Higher
// numbers are STRICTER (need a higher layer to be granted). Today's CoreAPI
// methods are all read or single-module-write; the deferred L3 acts
// (EnableTool / destructive Ops) are reserved at the top rung — they're
// not on the CoreAPI yet (the tool-executor module is unbuilt), but the
// authority table holds the rung so adding them is a policy entry, not a
// new mechanism.
type Authority int8

const (
	// AuthRead — read methods (LatestFact, LatestFactBySource, Since, Presence,
	// RecentOutcomes) and state mutations a module legitimately makes
	// (CreateReminder, MarkReminder, RecordNudge, ResolveNudge). The Enrollment
	// already gated caller identity; any enrolled module may use these.
	AuthRead Authority = 0

	// AuthWrite — WriteFact. Need enrollment + source-scope match. The
	// "compromised poller can't forge a trigger" property: a module only writes
	// sources it owns. Floor gets "*"; tight enrollments scope per source.
	AuthWrite Authority = 1

	// AuthStepUp — a per-assertion user-verification gesture is required for
	// this call. Reserved for EnableTool (registration-enable) and destructive
	// acts when they land on the CoreAPI. NOT a Layer itself — Authority is
	// the call-side requirement; Layer is the surface-side capability. The
	// pure check is just: does the surface cap (MaxLayer) carry L3, AND was
	// step-up asserted this session? Both settled by Can below.
	AuthStepUp Authority = 2
)

// Requirement — the PURE authority table: per-method required Authority. This
// is the one place in the codebase a method's required authority is declared;
// every other reference to "registration needs step-up" points back here.
// Adding a new ipc.Method = a row here (or it inherits AuthRead by default,
// which the vet check in dispatch catches via Method existence, not auth).
func Requirement(m ipc.Method) Authority {
	switch m {
	case ipc.MethodEnableTool, ipc.MethodDisableTool:
		// Both mutate the tool allowlist — the boundary. Enable adds a runnable
		// capability (privilege escalation); disable removes one (fail-safe
		// direction, but still an allowlist mutation and a lever an attacker
		// could pull to silence a security-relevant tool). Human-only, step-up
		// asserted — never a module or the voice/chat path. maven can propose
		// (MethodProposeTool, no step-up: she has no passkey) but never en/disable.
		return AuthStepUp
	case ipc.MethodSwapModel:
		// Swapping the resident model changes what routes every utterance and
		// what words every reply. It is the owner's call, from a surface that can
		// carry a passkey gesture — the same rung as mutating the tool allowlist,
		// and for the same reason: nothing Maven says or does may reach it.
		// MethodModelStatus is only the read side, so it stays at AuthRead.
		return AuthStepUp
	case ipc.MethodCaptureStart, ipc.MethodCaptureAppend, ipc.MethodCaptureStop:
		// Recording a meeting (Vikunja #253). AuthWrite, not AuthRead: it puts
		// audio of other people on disk, which is a heavier thing than reading a
		// fact, and it is not something a read-only surface should be able to
		// begin. Append and Stop sit on the same rung as Start deliberately —
		// a surface that may not start a recording has no business feeding or
		// harvesting one either.
		//
		// Not AuthStepUp, and this is the interesting line: step-up needs a
		// passkey gesture, which the voice path cannot make. Putting it here
		// would mean "запиши встречу" could never work by voice, and the real
		// gate on this capability is elsewhere and stronger — the methods do not
		// exist at all unless the operator enabled a capture block, and no
		// recording can begin without someone saying so.
		return AuthWrite
	case ipc.MethodEnrollSpeaker:
		// Taking a voiceprint (Vikunja #255). AuthStepUp, and unlike recording a
		// meeting there is no reason to soften it: enrolment is not a thing anyone
		// does by voice mid-conversation. It is a deliberate sit-down with a
		// surface that can carry a passkey gesture, and it writes a biometric of a
		// named person. If the gesture is inconvenient, that is the correct amount
		// of friction for this particular write.
		return AuthStepUp
	case ipc.MethodForgetSpeaker:
		// Deleting a voiceprint. One rung BELOW enrolment on purpose. Everywhere
		// else in this table the destructive direction is gated at least as hard
		// as the constructive one, and here that would be wrong: getting rid of a
		// biometric must never be the harder half. The worst a caller at this rung
		// can do is make Maven stop recognising someone, which is the state the
		// box ships in anyway.
		return AuthWrite
	case ipc.MethodSeedEvent:
		// The one backdating write path in the tree (Vikunja #518). AuthStepUp,
		// the same rung as mutating the tool allowlist, and for a reason that is
		// not about privilege: every other write records when something actually
		// happened, and this one asserts it. A caller who can place a fact in the
		// past can manufacture a routine Maven will then act on forever, which is
		// the tick loop obeying evidence nobody produced.
		//
		// Step-up is not the real gate and is not meant to be. mavend refuses the
		// method entirely unless started with -allow-seed, so the ordinary state
		// of the box is that no gesture reaches it. This rung is what stops a
		// module from calling it on a box where QA left the flag on.
		return AuthStepUp
	case ipc.MethodWriteFact:
		return AuthWrite
	case ipc.MethodIngestMail:
		// Mail ingestion (Vikunja #246). Moved up from AuthRead on 2026-08-01,
		// for consistency with SetTaskStatus rather than for a new threat: both
		// answer the same question — may this module change what is on his
		// lists? — and they were answering it differently. The old argument was
		// that ingestion is additive and can only produce candidate tasks, which
		// is still true; it is the weaker half of the argument, because a
		// compromised mail reader that can fill the review page indefinitely is
		// not a read.
		//
		// No caller loses anything: AuthWrite outside WriteFact only requires
		// enrollment, which mavmaild already has, and the method does not exist
		// unless the operator wired a mail block.
		return AuthWrite
	case ipc.MethodSetTaskStatus:
		// Resolving a task is NOT additive, which is what separates it from
		// capture. Capture at AuthRead can only put a line on a list he reads
		// himself; SetTaskStatus at AuthRead would let any enrolled module —
		// mavpoll, mavsttd — mark every open task done and clear the list out
		// from under him. Same reasoning as WriteFact: a module gets to add to
		// its own corner, not to erase his.
		return AuthWrite
	case ipc.MethodAssertStepUp:
		return AuthRead
	case ipc.MethodLatestFact,
		ipc.MethodLatestFactBySource,
		ipc.MethodSince,
		ipc.MethodPresence,
		ipc.MethodRecentOutcomes,
		ipc.MethodCreateReminder,
		ipc.MethodMarkReminder,
		ipc.MethodRecordNudge,
		ipc.MethodResolveNudge,
		// Task capture (Vikunja #130). Listed explicitly rather than left to
		// the default so the intent is on the record: capturing a task is a
		// module write, not an allowlist mutation and not a new standing reason
		// for Maven to speak — nothing in the tick loop reads tasks. It stays
		// at AuthRead, the same rung as CreateReminder, which is the closest
		// existing analogue. SetTaskStatus is NOT here: see the AuthWrite case
		// above, because resolving is the one task move that destroys
		// something.
		ipc.MethodCaptureTask,
		ipc.MethodListTasks,
		// Looking at one image (Vikunja #252). AuthRead because of what it can
		// produce: words about a picture, and optionally a note. It cannot write
		// a fact, set a reminder, or touch the tool allowlist. The invasive part
		// of this capability is not the authority rung — it is that the bytes are
		// kept on disk, which media.retention bounds, and that they never leave
		// the box, which internal/vision enforces by refusing a non-private
		// endpoint.
		ipc.MethodDescribeImage,
		// "что ты записываешь?" — the read side of the recorder. It reports a
		// label, a start time and a byte count, begins nothing and keeps nothing.
		ipc.MethodCaptureStatus,
		// Who is enrolled. Returns ids, names and enrolment dates — never the
		// voiceprints themselves, which stay in core. Listing the people Maven can
		// recognise is exactly the read a surface needs to offer a "forget" button.
		ipc.MethodListSpeakers,
		// The read side of the model swap: which model is resident, which ones are
		// allowlisted. It loads nothing and changes nothing.
		ipc.MethodModelStatus,
		// The unified intake journal (Vikunja #283). AuthRead, and listed
		// explicitly rather than inherited so the reasoning is on the record: it
		// reports what already arrived — sources, keys, note headlines — which is
		// the same material RecentFacts and RecentNotes already return at this
		// rung. It writes nothing, and it holds nothing a fact read does not.
		ipc.MethodRecentEvents:
		return AuthRead
	}
	// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
	// regardless of the auth verdict (we run before dispatch; we don't gate on
	// Method existence — Check is method-agnostic policy, not routing).
	return AuthRead
}

// Can — the PURE authority decision for one call. Returns nil if the scope
// is authorized to invoke m with the supplied params; an error otherwise:
//
//   - ErrUnenrolled — scope has no Module (caller wasn't in the enrollment
//     table). Fail closed.
//   - ErrForbidden — surface caps the layer below what m requires, or
//     WriteFact's source is out of scope.
//
// The adapter wrapping this for the ipc gate (Gate.Check) maps the error to
// codeForbidden at the wire; we keep the distinction here so daemon logs
// can show why a call was denied.
//
// params is the raw json.RawMessage the ipc Server received; for WriteFact we
// re-parse Source out of it. Other methods don't need params — their verdict
// depends only on the scope.
func Can(m ipc.Method, scope Scope, params json.RawMessage) error {
	// Floor closed: any caller not in the enrollment table is refused outright.
	// Not "0-level unauthed" — refused. This is the surface-caps property
	// applied before the layer caps: there is no L0 surface if SurfaceUnknown.
	if scope.Module == "" {
		return ErrUnenrolled
	}
	if scope.Surface == SurfaceUnknown {
		return ErrUnenrolled
	}

	switch Requirement(m) {
	case AuthRead:
		// Describing an image is a read. Saving the description as a note is
		// not: writeNote embeds it, so it comes back in a later turn as
		// something Maven knows, under the source media:image:<id>, which no
		// enrollment owns. The rung's own argument was that the method "cannot
		// write a fact, set a reminder, or touch the tool allowlist" — it can
		// write recall corpus, and that is what AuthWrite exists to scope. So
		// the note half is held to the same source-scope rule WriteFact is.
		if m == ipc.MethodDescribeImage && wantsNote(params) {
			if !SourceAllowed(scope.SourceScope, ImageNoteSource) {
				return fmt.Errorf("%w: source %q out of scope", ErrForbidden, ImageNoteSource)
			}
		}
		// Any enrolled module may read. Reads through the surface level the
		// Enrollment set (voice-L0 wouldn't be enrolled to write at all).
		return nil

	case AuthWrite:
		if m == ipc.MethodWriteFact {
			src, err := extractSource(params)
			if err != nil {
				// Malformed params is a bad-params error already produced by
				// ipc.dispatch; but Can runs first. Treat as forbidden — a
				// caller doesn't get to probe scopes with garbage params.
				return fmt.Errorf("%w: malformed source", ErrForbidden)
			}
			if !SourceAllowed(scope.SourceScope, src) {
				// The spec's compromised-poller case in one line: a poller
				// enrolled to write poll:healthcheck asking to write
				// poll:uptime is denied — but the same poller writing
				// poll:healthcheck is fine. Polls can't forge triggers.
				return fmt.Errorf("%w: source %q out of scope", ErrForbidden, src)
			}
		}
		return nil

	case AuthStepUp:
		// Surface-caps-authority enforced here. The surface can't carry L3 ⇒
		// forbidden. The session step-up itself is checked by the Gate (it
		// owns Session state and surfaces a Check function); we cap surface
		// here so the gate fails closed on shape alone.
		if MaxLayer(scope.Surface) < Layer3 {
			return fmt.Errorf("%w: surface %s can't carry step-up", ErrForbidden, scope.Surface)
		}
		return nil
	}
	return nil
}

// ImageNoteSource is the source scope a caller needs to turn a described image
// into a note. The note itself is stored under "media:image:<id-prefix>"; the
// scope is checked against this stem, because the id is not known until the
// bytes arrive and no enrollment could name it in advance.
const ImageNoteSource = "media:image"

// wantsNote reports whether a DescribeImage call asked for the description to
// be remembered. Malformed params read as no: dispatch rejects them a moment
// later with a better error.
func wantsNote(raw json.RawMessage) bool {
	if len(raw) == 0 {
		return false
	}
	var p ipc.DescribeImageReq
	if json.Unmarshal(raw, &p) != nil {
		return false
	}
	return p.SaveNote
}

// SourceAllowed — true iff src is in scope (the wildcard "*" matches all).
// Empty scope ⇒ fail closed. The function is pure; we keep it exported so a
// future enrollment table can call into the same matching logic.
func SourceAllowed(scope []string, src string) bool {
	if len(scope) == 0 {
		return false
	}
	for _, s := range scope {
		if s == "*" || s == src {
			return true
		}
	}
	return false
}

// extractSource reads WriteFactReq.Source out of the raw params WITHOUT a full
// unmarshal — Source is the only field Can needs, and re-parsing it once per
// write is cheap (and only happens on MethodWriteFact). Stay independent of
// any future WriteFactReq shape changes by using the struct directly.
func extractSource(raw json.RawMessage) (string, error) {
	var p ipc.WriteFactReq
	if err := json.Unmarshal(raw, &p); err != nil {
		return "", err
	}
	if p.Source == "" {
		// An empty source is rejected by store.WriteFact anyway; surface it
		// as forbidden to avoid giving a caller an ipc sentinel that names the
		// store's internal invariant. (store rejects this before feature flag
		// for "missing source"; today this is best-effort.)
		return "", errors.New("empty source")
	}
	return p.Source, nil
}
