package rbac

import (
	"net/http"
	"net/url"

	"nadir/internal/auditlog"
	"nadir/internal/auth"

	"github.com/danielgtaylor/huma/v2"
)

func RbacMiddleware(api huma.API, sessions *auth.SessionStore, tokens *auth.TokenAuth, roles *RBAC, auditor *auditlog.Store) func(huma.Context, func(huma.Context)) {
	return func(ctx huma.Context, next func(huma.Context)) {
		// CSRF defense-in-depth (beyond the SameSite=Strict cookie): reject any
		// state-changing request whose Origin doesn't match our Host. Runs before
		// the permission check so it also covers the public login/logout writes.
		//
		// Bearer tokens are exempt by construction: browsers don't auto-attach an
		// Authorization header, so a token request carries no ambient authority for
		// CSRF to exploit. Such requests also send no Origin, so they pass here too.
		if !sameOriginOK(ctx) {
			huma.WriteErr(api, ctx, http.StatusForbidden, "cross-origin request blocked")
			return
		}

		op := ctx.Operation()
		perm, _ := op.Metadata["permission"].(string)
		if perm == "" {
			next(ctx)
			return
		}
		moduleID, _ := op.Metadata["module"].(string)

		// Machine-to-machine: a Bearer token authenticates as its token name (a
		// dashboard managing N nodes needs no PAM session). Checked before the
		// cookie. The token name is the RBAC subject - assign it a role in
		// config.yaml's `assignments`, same as a username.
		if raw, isBearer := auth.BearerToken(ctx.Header("Authorization")); isBearer {
			if tokens == nil {
				huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
				return
			}
			name, ok, throttled := tokens.Verify(auth.ClientIP(ctx.Context()), raw)
			if throttled {
				huma.WriteErr(api, ctx, http.StatusTooManyRequests, "too many failed token attempts; wait a minute")
				return
			}
			if !ok {
				huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
				return
			}
			// Audit actor is prefixed "token:" so the trail distinguishes a token
			// from a human with the same name; RBAC still checks the bare name.
			if !roles.Can(name, moduleID, Permission(perm)) {
				huma.WriteErr(api, ctx, http.StatusForbidden, "forbidden")
				record(auditor, "token:"+name, op, ctx.URL().Path, http.StatusForbidden)
				return
			}
			next(ctx)
			record(auditor, "token:"+name, op, ctx.URL().Path, ctx.Status())
			return
		}

		cookie, err := huma.ReadCookie(ctx, "nadir_session_id")
		if err != nil || cookie == nil {
			huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
			return
		}
		sess, ok := sessions.GetByToken(cookie.Value)
		if !ok {
			huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
			return
		}
		if !roles.Can(sess.Username, moduleID, Permission(perm)) {
			huma.WriteErr(api, ctx, http.StatusForbidden, "forbidden")
			record(auditor, sess.Username, op, ctx.URL().Path, http.StatusForbidden)
			return
		}
		next(ctx)
		record(auditor, sess.Username, op, ctx.URL().Path, ctx.Status())
	}
}

// sameOriginOK allows safe methods and any request whose Origin header matches
// the request Host. A missing Origin (non-browser client, or a same-origin
// navigation) is allowed - CSRF is a browser-only concern. Only browser-issued
// cross-origin writes, which always carry an Origin, are rejected.
func sameOriginOK(ctx huma.Context) bool {
	switch ctx.Method() {
	case http.MethodGet, http.MethodHead, http.MethodOptions:
		return true
	}
	origin := ctx.Header("Origin")
	if origin == "" {
		return true
	}
	u, err := url.Parse(origin)
	if err != nil {
		return false
	}
	return u.Host == ctx.Host()
}

// record logs a mutation (anything but a read) to the audit trail. Reads are
// skipped to keep the trail to "who changed what". Best-effort: a logging
// failure is reported to the server log, never to the caller.
func record(auditor *auditlog.Store, username string, op *huma.Operation, path string, status int) {
	if op.Method == http.MethodGet || op.Method == http.MethodHead || op.Method == http.MethodOptions {
		return
	}
	// SSE handlers stream via BodyWriter and never call SetStatus, so huma's
	// context reports status 0 even though net/http has implicitly sent a 200.
	// A streamed response that reached here passed RBAC and started, so record
	// it as 200 (stream-level failures surface as `error` events, not codes).
	if status == 0 {
		status = http.StatusOK
	}
	moduleID, _ := op.Metadata["module"].(string)
	auditor.Record(username, op.Method, path, moduleID, status)
}
