package membership

// Per-subject data-plane access control derived from room membership (issue
// 0003e, audit H4 residual; tightened in issue 0006b for audit 0008 N2). The
// control plane already authorizes metadata by membership; this is the matching
// restriction on the NATS data plane so a registered peer can only
// publish/subscribe on the subjects of the rooms it actually belongs to — and can
// only reach the JetStream API of ITS OWN rooms' streams, never the control-plane
// KV buckets.

import (
	"encoding/hex"
	"fmt"
	"strings"

	"github.com/enmanuel/unibus/pkg/frame"
)

// clientInfraSubjects are the subjects every authorized peer needs regardless of
// room membership, kept deliberately MINIMAL (issue 0006b, audit 0008 N2):
//
//   - "_INBOX.>"     — request/reply plus the JetStream pull-consumer delivery
//     and publish-ack inboxes.
//   - "$JS.API.INFO" — account-level JetStream info (limits/usage counters). It
//     exposes NO room/user/key contents, so granting it leaks nothing.
//
// It NO LONGER contains "$JS.API.>". That broad grant was the N2 leak: it let any
// registered peer drive the whole JetStream API and read the control-plane KV
// buckets (KV_UNIBUS_users/rooms/members/room_keys) and the object store directly
// over NATS, bypassing the HTTP authorization (requireMember and the own-endpoint
// checks). JetStream API access is now granted PER ROOM, scoped to the stream of
// each room the peer belongs to (jsSubjectsFor). Because the control-plane KV
// streams (KV_UNIBUS_*) and the object store (OBJ_UNIBUS_*) are never a room
// stream, they fall outside the closed allow set and are denied by default.
var clientInfraSubjects = []string{"_INBOX.>", "$JS.API.INFO"}

// roomStreamName is the JetStream stream name a persisted room maps to. It MUST
// stay identical to pkg/client.streamName ("UNIBUS_" + sanitized roomID) so the
// per-room ACL grants exactly the subjects the client's JetStream calls use. Room
// ids are ULIDs (no '.'), so the sanitizing is a no-op in practice, but the rule
// is replicated defensively so the producer (client) and the authorizer (this
// ACL) never drift apart.
func roomStreamName(roomID string) string {
	var b strings.Builder
	b.Grow(len("UNIBUS_") + len(roomID))
	b.WriteString("UNIBUS_")
	for _, r := range roomID {
		switch {
		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_':
			b.WriteRune(r)
		default:
			b.WriteRune('_')
		}
	}
	return b.String()
}

// jsSubjectsFor returns the MINIMAL JetStream API subjects a peer needs to use the
// durable stream of ONE persisted room: create/update/info the stream, manage and
// pull from its durable consumer, and ack deliveries. Every subject embeds this
// room's stream name, so the grant cannot reach another room's stream nor any
// control-plane stream (KV_UNIBUS_* / OBJ_UNIBUS_*). The wildcard layout matches
// the NATS JetStream API subject grammar (the stream name is the trailing token
// of single-verb requests and follows a two-token verb for MSG.GET / MSG.NEXT /
// DURABLE.CREATE):
//
//	$JS.API.STREAM.<verb>.<stream>                 verb in {CREATE,UPDATE,INFO,DELETE,PURGE,...}
//	$JS.API.STREAM.MSG.<op>.<stream>               op    in {GET,DELETE}
//	$JS.API.CONSUMER.<verb>.<stream>               verb in {LIST,NAMES,CREATE(ephemeral)}
//	$JS.API.CONSUMER.<verb>.<stream>.<consumer>... verb in {CREATE,INFO,DELETE}
//	$JS.API.CONSUMER.<v1>.<v2>.<stream>.<cons>     {MSG.NEXT, DURABLE.CREATE}
//	$JS.ACK.<stream>.>                             message acknowledgements
func jsSubjectsFor(roomID string) []string {
	s := roomStreamName(roomID)
	return []string{
		"$JS.API.STREAM.*." + s,
		"$JS.API.STREAM.*.*." + s,
		"$JS.API.CONSUMER.*." + s,
		"$JS.API.CONSUMER.*." + s + ".>",
		"$JS.API.CONSUMER.*.*." + s + ".>",
		"$JS.ACK." + s + ".>",
	}
}

// SubjectACLFor returns a function that maps a signing public key (lowercase hex)
// to the data-plane subjects that identity may publish and subscribe to: the
// subject of every room it belongs to, the per-room JetStream API subjects of
// those rooms (so persisted-room history keeps working), plus the minimal client
// infrastructure subjects. It reads the live membership store, so the permissions
// reflect the identity's rooms at the moment it connects. A decode error or a
// store failure is returned as an error so the caller can fail closed (deny the
// connection) rather than grant open access.
//
// Because NATS freezes permissions at connect time, a peer invited to a new room
// after connecting must reconnect (client.RefreshSession) to pick up the new
// room's subject. The bus is the authoritative directory of subjects, so an
// unlisted subject is simply absent from the allow set.
func SubjectACLFor(store Store) func(signPubHex string) ([]string, error) {
	return func(signPubHex string) ([]string, error) {
		pub, err := hex.DecodeString(signPubHex)
		if err != nil || len(pub) != 32 {
			return nil, fmt.Errorf("acl: malformed sign pub %q", signPubHex)
		}
		endpoint := frame.EndpointID(pub)
		rooms, err := store.ListRoomsForEndpoint(endpoint)
		if err != nil {
			return nil, fmt.Errorf("acl: list rooms for %s: %w", endpoint, err)
		}
		// clientInfra + per room: the room subject + that room's JetStream API.
		subjects := make([]string, 0, len(clientInfraSubjects)+len(rooms)*7)
		subjects = append(subjects, clientInfraSubjects...)
		for _, r := range rooms {
			subjects = append(subjects, r.Subject)
			subjects = append(subjects, jsSubjectsFor(r.RoomID)...)
		}
		return subjects, nil
	}
}
