// Package access implements uopi's role-based access policy. Access is granted
// through group memberships: every user belongs implicitly to the built-in
// "public" group (granting the baseline viewer role), and may additionally be
// assigned a higher role in any number of named groups. Roles form a cumulative
// ladder — viewer < operator < logic-editor < auditor < admin — where each level
// includes all powers below it. A user's effective capability is the highest
// role they hold across all their groups.
//
// Groups can be nested: a member of a parent group holds that role on every
// descendant group too (unless the descendant assigns them a different role),
// so an admin of an organisation group administers its sub-teams.
//
// As a bootstrap convenience, a policy with no role assignments at all is treated
// as fully open (everyone is admin), matching an unconfigured/dev deployment.
// Assigning any role switches to strict mode where unlisted users are viewers.
//
// The policy is seeded from the TOML config at startup but is runtime-mutable
// through the admin pane: once EnablePersistence is called, every mutation is
// written to a JSON sidecar ({storageDir}/access.json) which, when present on a
// later startup, becomes the source of truth (the TOML config is then only a
// bootstrap seed). All access is guarded by an RWMutex and safe for concurrent
// use; the *Policy pointer is stable so existing shared-pointer wiring is
// preserved.
package access

import (
	"context"
	"encoding/json"
	"errors"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
)

// PublicGroup is the built-in group every user implicitly belongs to. It cannot
// be renamed or deleted and is always a top-level (parentless) group.
const PublicGroup = "public"

// Role is a cumulative access level granted by a group membership. Higher roles
// include every power of the lower ones.
type Role int

const (
	// RoleViewer permits reads only (no signal writes). It is the baseline every
	// user receives via the public group.
	RoleViewer Role = iota
	// RoleOperator adds signal writes (full HMI interaction).
	RoleOperator
	// RoleLogic adds editing panel and server-side control logic.
	RoleLogic
	// RoleAuditor adds viewing the audit log.
	RoleAuditor
	// RoleAdmin adds managing users, groups and access, and viewing server stats.
	RoleAdmin
)

// RoleNames lists the role tokens from lowest to highest, for the admin UI.
var RoleNames = []string{"viewer", "operator", "logiceditor", "auditor", "admin"}

// String renders the role using the tokens accepted by ParseRole.
func (r Role) String() string {
	switch r {
	case RoleOperator:
		return "operator"
	case RoleLogic:
		return "logiceditor"
	case RoleAuditor:
		return "auditor"
	case RoleAdmin:
		return "admin"
	default:
		return "viewer"
	}
}

// ParseRole maps a config/JSON token to a Role. Unknown values fall back to the
// least-privileged viewer.
func ParseRole(s string) Role {
	switch strings.ToLower(strings.TrimSpace(s)) {
	case "operator", "write", "operate", "rw":
		return RoleOperator
	case "logiceditor", "logic", "logic_editor", "editor":
		return RoleLogic
	case "auditor", "audit":
		return RoleAuditor
	case "admin", "administrator":
		return RoleAdmin
	default:
		return RoleViewer
	}
}

// Level is a coarse global access level retained for the rest of the codebase
// (WebSocket auth, middleware, per-panel ACL capping). It is derived from a
// user's effective role: viewer → read-only, operator and above → write.
type Level int

const (
	// LevelNone denies all access. Never produced by the role model, but kept so
	// existing switch statements remain exhaustive.
	LevelNone Level = iota
	// LevelRead permits reads only.
	LevelRead
	// LevelWrite permits full write access.
	LevelWrite
)

// String renders the level using the tokens surfaced to the frontend via
// /api/v1/me.
func (l Level) String() string {
	switch l {
	case LevelNone:
		return "none"
	case LevelRead:
		return "readonly"
	default:
		return "write"
	}
}

func roleToLevel(r Role) Level {
	if r >= RoleOperator {
		return LevelWrite
	}
	return LevelRead
}

// group holds a group's parent (for nesting) and explicit per-user role
// assignments.
type group struct {
	parent  string
	members map[string]Role
}

// Policy holds the resolved role-based access configuration. It is safe for
// concurrent use; reads take a shared lock and admin mutations take an exclusive
// lock and persist to disk.
type Policy struct {
	mu          sync.RWMutex
	path        string // access.json path; "" disables persistence
	defaultUser string // immutable after construction/load
	groups      map[string]*group
}

// GroupSpec seeds one group at construction time: its name, optional parent, and
// explicit user→role assignments.
type GroupSpec struct {
	Name    string
	Parent  string
	Members map[string]Role
}

// New builds a Policy from group specs. The built-in public group is always
// created. A spec listing the same user in multiple roles keeps the last one;
// callers should pass the highest intended role.
func New(defaultUser string, specs []GroupSpec) *Policy {
	p := &Policy{
		defaultUser: strings.TrimSpace(defaultUser),
		groups:      make(map[string]*group),
	}
	for _, s := range specs {
		name := strings.TrimSpace(s.Name)
		if name == "" {
			continue
		}
		g := p.ensureGroupLocked(name)
		g.parent = strings.TrimSpace(s.Parent)
		for u, r := range s.Members {
			if u = strings.TrimSpace(u); u != "" {
				g.members[u] = r
			}
		}
	}
	p.ensureGroupLocked(PublicGroup).parent = ""
	p.normalizeParentsLocked()
	return p
}

// ensureGroupLocked returns the named group, creating an empty one if needed.
// The caller must hold p.mu for writing (or be in construction).
func (p *Policy) ensureGroupLocked(name string) *group {
	g, ok := p.groups[name]
	if !ok {
		g = &group{members: make(map[string]Role)}
		p.groups[name] = g
	}
	return g
}

// normalizeParentsLocked drops parent references to missing groups or that would
// form a cycle, and forces the public group to be a root.
func (p *Policy) normalizeParentsLocked() {
	for name, g := range p.groups {
		if name == PublicGroup {
			g.parent = ""
			continue
		}
		if g.parent == "" {
			continue
		}
		if _, ok := p.groups[g.parent]; !ok || p.hasCycleLocked(name) {
			g.parent = ""
		}
	}
}

// hasCycleLocked reports whether following parent links from start loops back.
func (p *Policy) hasCycleLocked(start string) bool {
	seen := make(map[string]bool)
	for cur := start; cur != ""; {
		if seen[cur] {
			return true
		}
		seen[cur] = true
		g, ok := p.groups[cur]
		if !ok {
			return false
		}
		cur = g.parent
	}
	return false
}

// ── effective role ─────────────────────────────────────────────────────────

// configuredLocked reports whether any explicit role is assigned anywhere. An
// unconfigured policy is treated as fully open (bootstrap-safe).
func (p *Policy) configuredLocked() bool {
	for _, g := range p.groups {
		if len(g.members) > 0 {
			return true
		}
	}
	return false
}

// effectiveRoleLocked returns a user's highest role across all groups. Unlisted
// users (and anonymous callers) get the viewer baseline once the policy is
// configured; an unconfigured policy grants everyone admin.
func (p *Policy) effectiveRoleLocked(user string) Role {
	if !p.configuredLocked() {
		return RoleAdmin
	}
	best := RoleViewer
	if user = strings.TrimSpace(user); user == "" {
		return best
	}
	for _, g := range p.groups {
		if r, ok := g.members[user]; ok && r > best {
			best = r
		}
	}
	return best
}

// ── persistence ────────────────────────────────────────────────────────────

// persisted is the on-disk schema for access.json.
type persisted struct {
	DefaultUser string                    `json:"defaultUser"`
	Groups      map[string]persistedGroup `json:"groups"`
}

type persistedGroup struct {
	Parent  string            `json:"parent"`
	Members map[string]string `json:"members"` // user → role token
}

// EnablePersistence points the policy at {storageDir}/access.json. If the file
// exists its contents replace the TOML-seeded state (the file is the source of
// truth once written); otherwise the current state is kept and the file is only
// created on the first mutation.
func (p *Policy) EnablePersistence(storageDir string) error {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.path = filepath.Join(storageDir, "access.json")
	data, err := os.ReadFile(p.path)
	if errors.Is(err, os.ErrNotExist) {
		return nil
	}
	if err != nil {
		return err
	}
	var ps persisted
	if err := json.Unmarshal(data, &ps); err != nil {
		return err
	}
	p.defaultUser = strings.TrimSpace(ps.DefaultUser)
	p.groups = make(map[string]*group)
	for name, pg := range ps.Groups {
		if name = strings.TrimSpace(name); name == "" {
			continue
		}
		g := p.ensureGroupLocked(name)
		g.parent = strings.TrimSpace(pg.Parent)
		for u, token := range pg.Members {
			if u = strings.TrimSpace(u); u != "" {
				g.members[u] = ParseRole(token)
			}
		}
	}
	p.ensureGroupLocked(PublicGroup).parent = ""
	p.normalizeParentsLocked()
	return nil
}

// saveLocked atomically persists the current state when persistence is enabled.
func (p *Policy) saveLocked() error {
	if p.path == "" {
		return nil
	}
	ps := persisted{DefaultUser: p.defaultUser, Groups: make(map[string]persistedGroup, len(p.groups))}
	for name, g := range p.groups {
		pg := persistedGroup{Parent: g.parent, Members: make(map[string]string, len(g.members))}
		for u, r := range g.members {
			pg.Members[u] = r.String()
		}
		ps.Groups[name] = pg
	}
	data, err := json.MarshalIndent(ps, "", "  ")
	if err != nil {
		return err
	}
	tmp := p.path + ".tmp"
	if err := os.WriteFile(tmp, data, 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, p.path)
}

// ── reads ──────────────────────────────────────────────────────────────────

// ResolveUser trims the proxy-provided header value and falls back to the
// configured default_user when it is empty (e.g. unproxied/dev deployments).
func (p *Policy) ResolveUser(headerValue string) string {
	u := strings.TrimSpace(headerValue)
	if u == "" {
		return p.defaultUser
	}
	return u
}

// Level returns the coarse global access level for a user.
func (p *Policy) Level(user string) Level {
	p.mu.RLock()
	defer p.mu.RUnlock()
	return roleToLevel(p.effectiveRoleLocked(user))
}

// EffectiveRole returns the highest role a user holds across all groups.
func (p *Policy) EffectiveRole(user string) Role {
	p.mu.RLock()
	defer p.mu.RUnlock()
	return p.effectiveRoleLocked(user)
}

// CanEditLogic reports whether a user may add or edit panel and control logic
// (logic-editor role or higher).
func (p *Policy) CanEditLogic(user string) bool {
	p.mu.RLock()
	defer p.mu.RUnlock()
	return p.effectiveRoleLocked(user) >= RoleLogic
}

// CanViewAudit reports whether a user may view the audit log (auditor or admin).
func (p *Policy) CanViewAudit(user string) bool {
	p.mu.RLock()
	defer p.mu.RUnlock()
	return p.effectiveRoleLocked(user) >= RoleAuditor
}

// CanAdmin reports whether a user may use the admin pane (admin role).
func (p *Policy) CanAdmin(user string) bool {
	p.mu.RLock()
	defer p.mu.RUnlock()
	return p.effectiveRoleLocked(user) >= RoleAdmin
}

// GroupNames returns a copy of every group name, sorted.
func (p *Policy) GroupNames() []string {
	p.mu.RLock()
	defer p.mu.RUnlock()
	return p.groupNamesLocked()
}

func (p *Policy) groupNamesLocked() []string {
	out := make([]string, 0, len(p.groups))
	for n := range p.groups {
		out = append(out, n)
	}
	sort.Strings(out)
	return out
}

// GroupsOf returns the groups a user effectively belongs to: every group they
// are an explicit member of, plus that group's descendants (membership flows
// parent→child). The implicit public-group viewer baseline is not included.
// Used by per-panel/folder sharing rules.
func (p *Policy) GroupsOf(user string) []string {
	p.mu.RLock()
	defer p.mu.RUnlock()
	if user = strings.TrimSpace(user); user == "" {
		return nil
	}
	set := make(map[string]bool)
	for name, g := range p.groups {
		if _, ok := g.members[user]; ok {
			set[name] = true
			p.addDescendantsLocked(name, set)
		}
	}
	out := make([]string, 0, len(set))
	for n := range set {
		out = append(out, n)
	}
	sort.Strings(out)
	return out
}

// addDescendantsLocked adds every transitive child of parent into set.
func (p *Policy) addDescendantsLocked(parent string, set map[string]bool) {
	for name, g := range p.groups {
		if g.parent == parent && !set[name] {
			set[name] = true
			p.addDescendantsLocked(name, set)
		}
	}
}

// ── admin snapshot ─────────────────────────────────────────────────────────

// MemberInfo is one user's role within a group.
type MemberInfo struct {
	User string `json:"user"`
	Role string `json:"role"`
}

// GroupInfo describes one group for the admin pane.
type GroupInfo struct {
	Name    string       `json:"name"`
	Parent  string       `json:"parent"`
	Members []MemberInfo `json:"members"`
}

// UserInfo describes one user's memberships and resulting effective role.
type UserInfo struct {
	Name          string            `json:"name"`
	EffectiveRole string            `json:"effectiveRole"`
	Roles         map[string]string `json:"roles"` // group → role token
}

// AccessSnapshot is the full mutable access state, rendered for the admin pane.
type AccessSnapshot struct {
	DefaultUser string      `json:"defaultUser"`
	PublicGroup string      `json:"publicGroup"`
	Roles       []string    `json:"roles"` // role ladder, low→high
	Configured  bool        `json:"configured"`
	Users       []UserInfo  `json:"users"`
	Groups      []GroupInfo `json:"groups"`
}

// Snapshot returns a copy of the full access configuration for the admin pane.
func (p *Policy) Snapshot() AccessSnapshot {
	p.mu.RLock()
	defer p.mu.RUnlock()

	snap := AccessSnapshot{
		DefaultUser: p.defaultUser,
		PublicGroup: PublicGroup,
		Roles:       append([]string(nil), RoleNames...),
		Configured:  p.configuredLocked(),
	}

	userSet := make(map[string]bool)
	for _, name := range p.groupNamesLocked() {
		g := p.groups[name]
		gi := GroupInfo{Name: name, Parent: g.parent}
		users := make([]string, 0, len(g.members))
		for u := range g.members {
			users = append(users, u)
			userSet[u] = true
		}
		sort.Strings(users)
		for _, u := range users {
			gi.Members = append(gi.Members, MemberInfo{User: u, Role: g.members[u].String()})
		}
		snap.Groups = append(snap.Groups, gi)
	}

	users := make([]string, 0, len(userSet))
	for u := range userSet {
		users = append(users, u)
	}
	sort.Strings(users)
	for _, u := range users {
		ui := UserInfo{Name: u, EffectiveRole: p.effectiveRoleLocked(u).String(), Roles: make(map[string]string)}
		for name, g := range p.groups {
			if r, ok := g.members[u]; ok {
				ui.Roles[name] = r.String()
			}
		}
		snap.Users = append(snap.Users, ui)
	}
	return snap
}

// ── mutations (admin pane) ─────────────────────────────────────────────────

// ErrNotFound is returned when a named group does not exist.
var ErrNotFound = errors.New("group not found")

// SetMemberRole assigns a user a role within an existing group.
func (p *Policy) SetMemberRole(groupName, user string, role Role) error {
	groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
	if groupName == "" {
		return errors.New("empty group name")
	}
	if user == "" {
		return errors.New("empty user")
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	g, ok := p.groups[groupName]
	if !ok {
		return ErrNotFound
	}
	g.members[user] = role
	return p.saveLocked()
}

// RemoveMember drops a user's explicit role in a group.
func (p *Policy) RemoveMember(groupName, user string) error {
	groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
	p.mu.Lock()
	defer p.mu.Unlock()
	g, ok := p.groups[groupName]
	if !ok {
		return ErrNotFound
	}
	delete(g.members, user)
	return p.saveLocked()
}

// SetUserRoles replaces a user's full set of memberships: the user is placed in
// exactly the listed groups with the given roles and removed from all others.
// Listed groups that do not exist are created.
func (p *Policy) SetUserRoles(user string, roles map[string]Role) error {
	user = strings.TrimSpace(user)
	if user == "" {
		return errors.New("empty user")
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	want := make(map[string]Role, len(roles))
	for g, r := range roles {
		if g = strings.TrimSpace(g); g != "" {
			want[g] = r
			p.ensureGroupLocked(g)
		}
	}
	for name, g := range p.groups {
		if r, ok := want[name]; ok {
			g.members[user] = r
		} else {
			delete(g.members, user)
		}
	}
	p.normalizeParentsLocked()
	return p.saveLocked()
}

// CreateGroup adds an empty group with an optional parent if it does not exist.
func (p *Policy) CreateGroup(name, parent string) error {
	name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
	if name == "" {
		return errors.New("empty group name")
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	if _, ok := p.groups[name]; ok {
		return nil
	}
	g := p.ensureGroupLocked(name)
	g.parent = parent
	p.normalizeParentsLocked()
	return p.saveLocked()
}

// SetGroup replaces an existing group's parent and members. The public group's
// parent is always forced to root.
func (p *Policy) SetGroup(name, parent string, members map[string]Role) error {
	name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
	if name == "" {
		return errors.New("empty group name")
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	g, ok := p.groups[name]
	if !ok {
		return ErrNotFound
	}
	if name != PublicGroup {
		g.parent = parent
	}
	g.members = make(map[string]Role, len(members))
	for u, r := range members {
		if u = strings.TrimSpace(u); u != "" {
			g.members[u] = r
		}
	}
	p.normalizeParentsLocked()
	return p.saveLocked()
}

// RenameGroup renames a group, re-pointing any children at the new name. The
// public group cannot be renamed.
func (p *Policy) RenameGroup(oldName, newName string) error {
	oldName, newName = strings.TrimSpace(oldName), strings.TrimSpace(newName)
	if oldName == "" || newName == "" {
		return errors.New("empty group name")
	}
	if oldName == PublicGroup {
		return errors.New("cannot rename the public group")
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	g, ok := p.groups[oldName]
	if !ok {
		return ErrNotFound
	}
	if oldName == newName {
		return nil
	}
	if _, exists := p.groups[newName]; exists {
		return errors.New("group already exists: " + newName)
	}
	delete(p.groups, oldName)
	p.groups[newName] = g
	for _, other := range p.groups {
		if other.parent == oldName {
			other.parent = newName
		}
	}
	return p.saveLocked()
}

// DeleteGroup removes a group, reparenting its children to the deleted group's
// parent. The public group cannot be deleted; member users keep other groups.
func (p *Policy) DeleteGroup(name string) error {
	name = strings.TrimSpace(name)
	if name == PublicGroup {
		return errors.New("cannot delete the public group")
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	g, ok := p.groups[name]
	if !ok {
		return ErrNotFound
	}
	parent := g.parent
	delete(p.groups, name)
	for _, other := range p.groups {
		if other.parent == name {
			other.parent = parent
		}
	}
	p.normalizeParentsLocked()
	return p.saveLocked()
}

// ── request-scoped user identity ───────────────────────────────────────────

type ctxKey struct{}

// WithUser returns a copy of ctx carrying the resolved end-user identity.
func WithUser(ctx context.Context, user string) context.Context {
	return context.WithValue(ctx, ctxKey{}, user)
}

// UserFrom returns the identity stored by WithUser, or "" if none.
func UserFrom(ctx context.Context) string {
	u, _ := ctx.Value(ctxKey{}).(string)
	return u
}
