// Command mciasctl is the MCIAS admin CLI.
//
// It connects to a running mciassrv instance and provides subcommands for
// managing accounts, roles, tokens, Postgres credentials, policy rules, and
// account tags.
//
// Usage:
//
//	mciasctl [global flags] <command> [args]
//
// Global flags:
//
//	-server  URL of the mciassrv instance (default: https://mcias.metacircular.net:8443)
//	-token   Bearer token for authentication (or set MCIAS_TOKEN env var)
//	-cacert  Path to CA certificate for TLS verification (optional)
//
// Commands:
//
//	auth login           -username NAME [-totp CODE]
//	auth change-password (passwords always prompted interactively)
//
//	account list
//	account create       -username NAME [-type human|system]
//	account get          -id UUID
//	account update       -id UUID [-status active|inactive]
//	account delete       -id UUID
//	account set-password -id UUID
//
//	role list   -id UUID
//	role set    -id UUID -roles role1,role2,...
//	role grant  -id UUID -role ROLE
//	role revoke -id UUID -role ROLE
//
//	token issue  -id UUID
//	token revoke -jti JTI
//
//	pgcreds list
//	pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
//	pgcreds get -id UUID
//
//	policy list
//	policy create  -description STR -json FILE [-priority N] [-not-before RFC3339] [-expires-at RFC3339]
//	policy get     -id ID
//	policy update  -id ID [-priority N] [-enabled true|false] [-not-before RFC3339] [-expires-at RFC3339] [-clear-not-before] [-clear-expires-at]
//	policy delete  -id ID
//
//	tag list    -id UUID
//	tag set     -id UUID -tags tag1,tag2,...
package main

import (
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"flag"
	"fmt"
	"net/http"
	"os"
	"strings"
	"time"

	"golang.org/x/term"
)

func main() {
	// Global flags.
	serverURL := flag.String("server", "https://mcias.metacircular.net:8443", "mciassrv base URL")
	tokenFlag := flag.String("token", "", "bearer token (or set MCIAS_TOKEN)")
	caCert := flag.String("cacert", "", "path to CA certificate for TLS")
	flag.Usage = usage
	flag.Parse()

	// Resolve token from flag or environment.
	bearerToken := *tokenFlag
	if bearerToken == "" {
		bearerToken = os.Getenv("MCIAS_TOKEN")
	}

	args := flag.Args()
	if len(args) == 0 {
		usage()
		os.Exit(1)
	}

	// Build HTTP client.
	client, err := newHTTPClient(*caCert)
	if err != nil {
		fatalf("build HTTP client: %v", err)
	}

	ctl := &controller{
		serverURL: strings.TrimRight(*serverURL, "/"),
		token:     bearerToken,
		client:    client,
	}

	command := args[0]
	subArgs := args[1:]

	switch command {
	case "auth":
		ctl.runAuth(subArgs)
	case "account":
		ctl.runAccount(subArgs)
	case "role":
		ctl.runRole(subArgs)
	case "token":
		ctl.runToken(subArgs)
	case "pgcreds":
		ctl.runPGCreds(subArgs)
	case "policy":
		ctl.runPolicy(subArgs)
	case "tag":
		ctl.runTag(subArgs)
	default:
		fatalf("unknown command %q; run with no args to see usage", command)
	}
}

// controller holds shared state for all subcommands.
type controller struct {
	client    *http.Client
	serverURL string
	token     string
}

// ---- auth subcommands ----

func (c *controller) runAuth(args []string) {
	if len(args) == 0 {
		fatalf("auth requires a subcommand: login, change-password")
	}
	switch args[0] {
	case "login":
		c.authLogin(args[1:])
	case "change-password":
		c.authChangePassword(args[1:])
	default:
		fatalf("unknown auth subcommand %q", args[0])
	}
}

// authLogin authenticates with the server using username and password, then
// prints the resulting bearer token to stdout. The password is always prompted
// interactively; it is never accepted as a command-line flag to prevent it from
// appearing in shell history, ps output, and process argument lists.
//
// Security: terminal echo is disabled during password entry
// (golang.org/x/term.ReadPassword); the raw byte slice is zeroed after use.
func (c *controller) authLogin(args []string) {
	fs := flag.NewFlagSet("auth login", flag.ExitOnError)
	username := fs.String("username", "", "username (required)")
	totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)")
	_ = fs.Parse(args)

	if *username == "" {
		fatalf("auth login: -username is required")
	}

	// Security: always prompt interactively; never accept password as a flag.
	// This prevents the credential from appearing in shell history, ps output,
	// and /proc/PID/cmdline.
	fmt.Fprint(os.Stderr, "Password: ")
	raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
	fmt.Fprintln(os.Stderr)                           // newline after hidden input
	if err != nil {
		fatalf("read password: %v", err)
	}
	passwd := string(raw)
	// Zero the raw byte slice once copied into the string.
	for i := range raw {
		raw[i] = 0
	}

	body := map[string]string{
		"username": *username,
		"password": passwd,
	}
	if *totpCode != "" {
		body["totp_code"] = *totpCode
	}

	var result struct {
		Token     string `json:"token"`
		ExpiresAt string `json:"expires_at"`
	}
	c.doRequest("POST", "/v1/auth/login", body, &result)

	// Print token to stdout so it can be captured by scripts, e.g.:
	//   export MCIAS_TOKEN=$(mciasctl auth login -username alice)
	fmt.Println(result.Token)
	if result.ExpiresAt != "" {
		fmt.Fprintf(os.Stderr, "expires: %s\n", result.ExpiresAt)
	}
}

// authChangePassword allows an authenticated user to change their own password.
// A valid bearer token must be set (via -token flag or MCIAS_TOKEN env var).
// Both passwords are always prompted interactively; they are never accepted as
// command-line flags to prevent them from appearing in shell history, ps
// output, and process argument lists.
//
// Security: terminal echo is disabled during entry (golang.org/x/term);
// raw byte slices are zeroed after use.  The server requires the current
// password to prevent token-theft attacks.  On success all other active
// sessions are revoked server-side.
func (c *controller) authChangePassword(_ []string) {
	// Security: always prompt interactively; never accept passwords as flags.
	fmt.Fprint(os.Stderr, "Current password: ")
	rawCurrent, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
	fmt.Fprintln(os.Stderr)
	if err != nil {
		fatalf("read current password: %v", err)
	}
	currentPasswd := string(rawCurrent)
	for i := range rawCurrent {
		rawCurrent[i] = 0
	}

	fmt.Fprint(os.Stderr, "New password: ")
	rawNew, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
	fmt.Fprintln(os.Stderr)
	if err != nil {
		fatalf("read new password: %v", err)
	}
	newPasswd := string(rawNew)
	for i := range rawNew {
		rawNew[i] = 0
	}

	body := map[string]string{
		"current_password": currentPasswd,
		"new_password":     newPasswd,
	}
	c.doRequest("PUT", "/v1/auth/password", body, nil)
	fmt.Println("password changed; other active sessions revoked")
}

// ---- account subcommands ----

func (c *controller) runAccount(args []string) {
	if len(args) == 0 {
		fatalf("account requires a subcommand: list, create, get, update, delete, set-password")
	}
	switch args[0] {
	case "list":
		c.accountList()
	case "create":
		c.accountCreate(args[1:])
	case "get":
		c.accountGet(args[1:])
	case "update":
		c.accountUpdate(args[1:])
	case "delete":
		c.accountDelete(args[1:])
	case "set-password":
		c.accountSetPassword(args[1:])
	default:
		fatalf("unknown account subcommand %q", args[0])
	}
}

func (c *controller) accountList() {
	var result []json.RawMessage
	c.doRequest("GET", "/v1/accounts", nil, &result)
	printJSON(result)
}

func (c *controller) accountCreate(args []string) {
	fs := flag.NewFlagSet("account create", flag.ExitOnError)
	username := fs.String("username", "", "username (required)")
	accountType := fs.String("type", "human", "account type: human or system")
	_ = fs.Parse(args)

	if *username == "" {
		fatalf("account create: -username is required")
	}

	// Security: always prompt interactively for human-account passwords; never
	// accept them as a flag.  Terminal echo is disabled; the raw byte slice is
	// zeroed after conversion to string.  System accounts have no password.
	var passwd string
	if *accountType == "human" {
		fmt.Fprint(os.Stderr, "Password: ")
		raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
		fmt.Fprintln(os.Stderr)
		if err != nil {
			fatalf("read password: %v", err)
		}
		passwd = string(raw)
		for i := range raw {
			raw[i] = 0
		}
	}

	body := map[string]string{
		"username":     *username,
		"account_type": *accountType,
	}
	if passwd != "" {
		body["password"] = passwd
	}

	var result json.RawMessage
	c.doRequest("POST", "/v1/accounts", body, &result)
	printJSON(result)
}

func (c *controller) accountGet(args []string) {
	fs := flag.NewFlagSet("account get", flag.ExitOnError)
	id := fs.String("id", "", "account UUID (required)")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("account get: -id is required")
	}

	var result json.RawMessage
	c.doRequest("GET", "/v1/accounts/"+*id, nil, &result)
	printJSON(result)
}

func (c *controller) accountUpdate(args []string) {
	fs := flag.NewFlagSet("account update", flag.ExitOnError)
	id := fs.String("id", "", "account UUID (required)")
	status := fs.String("status", "", "new status: active or inactive")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("account update: -id is required")
	}
	if *status == "" {
		fatalf("account update: -status is required")
	}

	body := map[string]string{"status": *status}
	c.doRequest("PATCH", "/v1/accounts/"+*id, body, nil)
	fmt.Println("account updated")
}

func (c *controller) accountDelete(args []string) {
	fs := flag.NewFlagSet("account delete", flag.ExitOnError)
	id := fs.String("id", "", "account UUID (required)")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("account delete: -id is required")
	}

	c.doRequest("DELETE", "/v1/accounts/"+*id, nil, nil)
	fmt.Println("account deleted")
}

// accountSetPassword resets a human account's password (admin operation).
// No current password is required.  All active sessions for the target account
// are revoked by the server on success.
//
// Security: the new password is always prompted interactively; it is never
// accepted as a command-line flag to prevent it from appearing in shell
// history, ps output, and process argument lists.  Terminal echo is disabled
// (golang.org/x/term); the raw byte slice is zeroed after use.
func (c *controller) accountSetPassword(args []string) {
	fs := flag.NewFlagSet("account set-password", flag.ExitOnError)
	id := fs.String("id", "", "account UUID (required)")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("account set-password: -id is required")
	}

	// Security: always prompt interactively; never accept password as a flag.
	fmt.Fprint(os.Stderr, "New password: ")
	raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
	fmt.Fprintln(os.Stderr)
	if err != nil {
		fatalf("read password: %v", err)
	}
	passwd := string(raw)
	for i := range raw {
		raw[i] = 0
	}

	body := map[string]string{"new_password": passwd}
	c.doRequest("PUT", "/v1/accounts/"+*id+"/password", body, nil)
	fmt.Println("password updated; all active sessions revoked")
}

// ---- role subcommands ----

func (c *controller) runRole(args []string) {
	if len(args) == 0 {
		fatalf("role requires a subcommand: list, set, grant, revoke")
	}
	switch args[0] {
	case "list":
		c.roleList(args[1:])
	case "set":
		c.roleSet(args[1:])
	case "grant":
		c.roleGrant(args[1:])
	case "revoke":
		c.roleRevoke(args[1:])
	default:
		fatalf("unknown role subcommand %q", args[0])
	}
}

func (c *controller) roleList(args []string) {
	fs := flag.NewFlagSet("role list", flag.ExitOnError)
	id := fs.String("id", "", "account UUID (required)")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("role list: -id is required")
	}

	var result json.RawMessage
	c.doRequest("GET", "/v1/accounts/"+*id+"/roles", nil, &result)
	printJSON(result)
}

func (c *controller) roleSet(args []string) {
	fs := flag.NewFlagSet("role set", flag.ExitOnError)
	id := fs.String("id", "", "account UUID (required)")
	rolesFlag := fs.String("roles", "", "comma-separated list of roles")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("role set: -id is required")
	}

	roles := []string{}
	if *rolesFlag != "" {
		for _, r := range strings.Split(*rolesFlag, ",") {
			r = strings.TrimSpace(r)
			if r != "" {
				roles = append(roles, r)
			}
		}
	}

	body := map[string][]string{"roles": roles}
	c.doRequest("PUT", "/v1/accounts/"+*id+"/roles", body, nil)
	fmt.Printf("roles set: %v\n", roles)
}

func (c *controller) roleGrant(args []string) {
	fs := flag.NewFlagSet("role grant", flag.ExitOnError)
	id := fs.String("id", "", "account UUID (required)")
	role := fs.String("role", "", "role name (required)")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("role grant: -id is required")
	}
	if *role == "" {
		fatalf("role grant: -role is required")
	}

	body := map[string]string{"role": *role}
	c.doRequest("POST", "/v1/accounts/"+*id+"/roles", body, nil)
	fmt.Printf("role granted: %s\n", *role)
}

func (c *controller) roleRevoke(args []string) {
	fs := flag.NewFlagSet("role revoke", flag.ExitOnError)
	id := fs.String("id", "", "account UUID (required)")
	role := fs.String("role", "", "role name (required)")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("role revoke: -id is required")
	}
	if *role == "" {
		fatalf("role revoke: -role is required")
	}

	c.doRequest("DELETE", "/v1/accounts/"+*id+"/roles/"+*role, nil, nil)
	fmt.Printf("role revoked: %s\n", *role)
}

// ---- token subcommands ----

func (c *controller) runToken(args []string) {
	if len(args) == 0 {
		fatalf("token requires a subcommand: issue, revoke")
	}
	switch args[0] {
	case "issue":
		c.tokenIssue(args[1:])
	case "revoke":
		c.tokenRevoke(args[1:])
	default:
		fatalf("unknown token subcommand %q", args[0])
	}
}

func (c *controller) tokenIssue(args []string) {
	fs := flag.NewFlagSet("token issue", flag.ExitOnError)
	id := fs.String("id", "", "system account UUID (required)")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("token issue: -id is required")
	}

	body := map[string]string{"account_id": *id}
	var result json.RawMessage
	c.doRequest("POST", "/v1/token/issue", body, &result)
	printJSON(result)
}

func (c *controller) tokenRevoke(args []string) {
	fs := flag.NewFlagSet("token revoke", flag.ExitOnError)
	jti := fs.String("jti", "", "JTI of the token to revoke (required)")
	_ = fs.Parse(args)

	if *jti == "" {
		fatalf("token revoke: -jti is required")
	}

	c.doRequest("DELETE", "/v1/token/"+*jti, nil, nil)
	fmt.Println("token revoked")
}

// ---- pgcreds subcommands ----

func (c *controller) runPGCreds(args []string) {
	if len(args) == 0 {
		fatalf("pgcreds requires a subcommand: list, get, set")
	}
	switch args[0] {
	case "list":
		c.pgCredsList(args[1:])
	case "get":
		c.pgCredsGet(args[1:])
	case "set":
		c.pgCredsSet(args[1:])
	default:
		fatalf("unknown pgcreds subcommand %q", args[0])
	}
}

func (c *controller) pgCredsList(args []string) {
	fs := flag.NewFlagSet("pgcreds list", flag.ExitOnError)
	_ = fs.Parse(args)

	var result json.RawMessage
	c.doRequest("GET", "/v1/pgcreds", nil, &result)
	printJSON(result)
}

func (c *controller) pgCredsGet(args []string) {
	fs := flag.NewFlagSet("pgcreds get", flag.ExitOnError)
	id := fs.String("id", "", "account UUID (required)")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("pgcreds get: -id is required")
	}

	var result json.RawMessage
	c.doRequest("GET", "/v1/accounts/"+*id+"/pgcreds", nil, &result)
	printJSON(result)
}

func (c *controller) pgCredsSet(args []string) {
	fs := flag.NewFlagSet("pgcreds set", flag.ExitOnError)
	id := fs.String("id", "", "account UUID (required)")
	host := fs.String("host", "", "Postgres host (required)")
	port := fs.Int("port", 5432, "Postgres port")
	dbName := fs.String("db", "", "Postgres database name (required)")
	username := fs.String("user", "", "Postgres username (required)")
	password := fs.String("password", "", "Postgres password (prompted if omitted)")
	_ = fs.Parse(args)

	if *id == "" || *host == "" || *dbName == "" || *username == "" {
		fatalf("pgcreds set: -id, -host, -db, and -user are required")
	}

	// Prompt for the Postgres password interactively if not supplied so it
	// stays out of shell history.
	// Security: terminal echo is disabled during entry; the raw byte slice is
	// zeroed after conversion to string.
	passwd := *password
	if passwd == "" {
		fmt.Fprint(os.Stderr, "Postgres password: ")
		raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms
		fmt.Fprintln(os.Stderr)
		if err != nil {
			fatalf("read password: %v", err)
		}
		passwd = string(raw)
		for i := range raw {
			raw[i] = 0
		}
	}

	body := map[string]interface{}{
		"host":     *host,
		"port":     *port,
		"database": *dbName,
		"username": *username,
		"password": passwd,
	}
	c.doRequest("PUT", "/v1/accounts/"+*id+"/pgcreds", body, nil)
	fmt.Println("credentials stored")
}

// ---- policy subcommands ----

func (c *controller) runPolicy(args []string) {
	if len(args) == 0 {
		fatalf("policy requires a subcommand: list, create, get, update, delete")
	}
	switch args[0] {
	case "list":
		c.policyList()
	case "create":
		c.policyCreate(args[1:])
	case "get":
		c.policyGet(args[1:])
	case "update":
		c.policyUpdate(args[1:])
	case "delete":
		c.policyDelete(args[1:])
	default:
		fatalf("unknown policy subcommand %q", args[0])
	}
}

func (c *controller) policyList() {
	var result json.RawMessage
	c.doRequest("GET", "/v1/policy/rules", nil, &result)
	printJSON(result)
}

func (c *controller) policyCreate(args []string) {
	fs := flag.NewFlagSet("policy create", flag.ExitOnError)
	description := fs.String("description", "", "rule description (required)")
	jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)")
	priority := fs.Int("priority", 100, "rule priority (lower = evaluated first)")
	notBefore := fs.String("not-before", "", "earliest activation time (RFC3339, optional)")
	expiresAt := fs.String("expires-at", "", "expiry time (RFC3339, optional)")
	_ = fs.Parse(args)

	if *description == "" {
		fatalf("policy create: -description is required")
	}
	if *jsonFile == "" {
		fatalf("policy create: -json is required (path to rule body JSON file)")
	}

	// G304: path comes from a CLI flag supplied by the operator.
	ruleBytes, err := os.ReadFile(*jsonFile) //nolint:gosec
	if err != nil {
		fatalf("policy create: read %s: %v", *jsonFile, err)
	}

	// Validate that the file contains valid JSON before sending.
	var ruleBody json.RawMessage
	if err := json.Unmarshal(ruleBytes, &ruleBody); err != nil {
		fatalf("policy create: invalid JSON in %s: %v", *jsonFile, err)
	}

	body := map[string]interface{}{
		"description": *description,
		"priority":    *priority,
		"rule":        ruleBody,
	}
	if *notBefore != "" {
		if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
			fatalf("policy create: -not-before must be RFC3339: %v", err)
		}
		body["not_before"] = *notBefore
	}
	if *expiresAt != "" {
		if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
			fatalf("policy create: -expires-at must be RFC3339: %v", err)
		}
		body["expires_at"] = *expiresAt
	}

	var result json.RawMessage
	c.doRequest("POST", "/v1/policy/rules", body, &result)
	printJSON(result)
}

func (c *controller) policyGet(args []string) {
	fs := flag.NewFlagSet("policy get", flag.ExitOnError)
	id := fs.String("id", "", "rule ID (required)")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("policy get: -id is required")
	}

	var result json.RawMessage
	c.doRequest("GET", "/v1/policy/rules/"+*id, nil, &result)
	printJSON(result)
}

func (c *controller) policyUpdate(args []string) {
	fs := flag.NewFlagSet("policy update", flag.ExitOnError)
	id := fs.String("id", "", "rule ID (required)")
	priority := fs.Int("priority", -1, "new priority (-1 = no change)")
	enabled := fs.String("enabled", "", "true or false")
	notBefore := fs.String("not-before", "", "earliest activation time (RFC3339)")
	expiresAt := fs.String("expires-at", "", "expiry time (RFC3339)")
	clearNotBefore := fs.Bool("clear-not-before", false, "remove not_before constraint")
	clearExpiresAt := fs.Bool("clear-expires-at", false, "remove expires_at constraint")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("policy update: -id is required")
	}

	body := map[string]interface{}{}
	if *priority >= 0 {
		body["priority"] = *priority
	}
	if *enabled != "" {
		switch *enabled {
		case "true":
			b := true
			body["enabled"] = b
		case "false":
			b := false
			body["enabled"] = b
		default:
			fatalf("policy update: -enabled must be true or false")
		}
	}
	if *clearNotBefore {
		body["clear_not_before"] = true
	} else if *notBefore != "" {
		if _, err := time.Parse(time.RFC3339, *notBefore); err != nil {
			fatalf("policy update: -not-before must be RFC3339: %v", err)
		}
		body["not_before"] = *notBefore
	}
	if *clearExpiresAt {
		body["clear_expires_at"] = true
	} else if *expiresAt != "" {
		if _, err := time.Parse(time.RFC3339, *expiresAt); err != nil {
			fatalf("policy update: -expires-at must be RFC3339: %v", err)
		}
		body["expires_at"] = *expiresAt
	}
	if len(body) == 0 {
		fatalf("policy update: at least one flag is required")
	}

	var result json.RawMessage
	c.doRequest("PATCH", "/v1/policy/rules/"+*id, body, &result)
	printJSON(result)
}

func (c *controller) policyDelete(args []string) {
	fs := flag.NewFlagSet("policy delete", flag.ExitOnError)
	id := fs.String("id", "", "rule ID (required)")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("policy delete: -id is required")
	}

	c.doRequest("DELETE", "/v1/policy/rules/"+*id, nil, nil)
	fmt.Println("policy rule deleted")
}

// ---- tag subcommands ----

func (c *controller) runTag(args []string) {
	if len(args) == 0 {
		fatalf("tag requires a subcommand: list, set")
	}
	switch args[0] {
	case "list":
		c.tagList(args[1:])
	case "set":
		c.tagSet(args[1:])
	default:
		fatalf("unknown tag subcommand %q", args[0])
	}
}

func (c *controller) tagList(args []string) {
	fs := flag.NewFlagSet("tag list", flag.ExitOnError)
	id := fs.String("id", "", "account UUID (required)")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("tag list: -id is required")
	}

	var result json.RawMessage
	c.doRequest("GET", "/v1/accounts/"+*id+"/tags", nil, &result)
	printJSON(result)
}

func (c *controller) tagSet(args []string) {
	fs := flag.NewFlagSet("tag set", flag.ExitOnError)
	id := fs.String("id", "", "account UUID (required)")
	tagsFlag := fs.String("tags", "", "comma-separated list of tags (empty string clears all tags)")
	_ = fs.Parse(args)

	if *id == "" {
		fatalf("tag set: -id is required")
	}

	tags := []string{}
	if *tagsFlag != "" {
		for _, t := range strings.Split(*tagsFlag, ",") {
			t = strings.TrimSpace(t)
			if t != "" {
				tags = append(tags, t)
			}
		}
	}

	body := map[string][]string{"tags": tags}
	c.doRequest("PUT", "/v1/accounts/"+*id+"/tags", body, nil)
	fmt.Printf("tags set: %v\n", tags)
}

// ---- HTTP helpers ----

// doRequest performs an authenticated JSON HTTP request. If result is non-nil,
// the response body is decoded into it. Exits on error.
func (c *controller) doRequest(method, path string, body, result interface{}) {
	url := c.serverURL + path

	var bodyReader *strings.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			fatalf("marshal request body: %v", err)
		}
		bodyReader = strings.NewReader(string(b))
	} else {
		bodyReader = strings.NewReader("")
	}

	req, err := http.NewRequest(method, url, bodyReader)
	if err != nil {
		fatalf("create request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")
	if c.token != "" {
		req.Header.Set("Authorization", "Bearer "+c.token)
	}

	resp, err := c.client.Do(req)
	if err != nil {
		fatalf("HTTP %s %s: %v", method, path, err)
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode >= 400 {
		var errBody map[string]string
		_ = json.NewDecoder(resp.Body).Decode(&errBody)
		msg := errBody["error"]
		if msg == "" {
			msg = resp.Status
		}
		fatalf("server returned %d: %s", resp.StatusCode, msg)
	}

	if result != nil && resp.StatusCode != http.StatusNoContent {
		if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
			fatalf("decode response: %v", err)
		}
	}
}

// newHTTPClient builds an http.Client with optional custom CA certificate.
// Security: TLS 1.2+ is required; the system CA pool is used by default.
func newHTTPClient(caCertPath string) (*http.Client, error) {
	tlsCfg := &tls.Config{
		MinVersion: tls.VersionTLS12,
	}

	if caCertPath != "" {
		// G304: path comes from a CLI flag supplied by the operator, not from
		// untrusted input. File inclusion is intentional.
		pemData, err := os.ReadFile(caCertPath) //nolint:gosec
		if err != nil {
			return nil, fmt.Errorf("read CA cert: %w", err)
		}
		pool := x509.NewCertPool()
		if !pool.AppendCertsFromPEM(pemData) {
			return nil, fmt.Errorf("no valid certificates found in %s", caCertPath)
		}
		tlsCfg.RootCAs = pool
	}

	return &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: tlsCfg,
		},
		Timeout: 30 * time.Second,
	}, nil
}

// printJSON pretty-prints a JSON value to stdout.
func printJSON(v interface{}) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fatalf("encode output: %v", err)
	}
}

// fatalf prints an error message and exits with code 1.
func fatalf(format string, args ...interface{}) {
	fmt.Fprintf(os.Stderr, "mciasctl: "+format+"\n", args...)
	os.Exit(1)
}

func usage() {
	fmt.Fprintf(os.Stderr, `mciasctl - MCIAS admin CLI

Usage: mciasctl [global flags] <command> [args]

Global flags:
  -server  URL of the mciassrv instance (default: https://mcias.metacircular.net:8443)
  -token   Bearer token (or set MCIAS_TOKEN env var)
  -cacert  Path to CA certificate for TLS verification

Commands:
  auth login  -username NAME [-totp CODE]
              Obtain a bearer token. Password is always prompted interactively
              (never accepted as a flag) to avoid shell-history exposure.
              Token is written to stdout; expiry to stderr.
              Example: export MCIAS_TOKEN=$(mciasctl auth login -username alice)
  auth change-password
              Change the current user's own password. Requires a valid bearer
              token. Current and new passwords are always prompted interactively.
              Revokes all other active sessions on success.

  account list
  account create       -username NAME [-type human|system]
  account get          -id UUID
  account update       -id UUID -status active|inactive
  account delete       -id UUID
  account set-password -id UUID
              Admin: reset a human account's password without requiring the
              current password. New password is always prompted interactively.
              Revokes all active sessions for the account.

  role list  -id UUID
  role set   -id UUID -roles role1,role2,...

  token issue   -id UUID
  token revoke  -jti JTI

  pgcreds list
  pgcreds get  -id UUID
  pgcreds set  -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]

  policy list
  policy create  -description STR -json FILE [-priority N]
                 [-not-before RFC3339] [-expires-at RFC3339]
                 FILE must contain a JSON rule body, e.g.:
                 {"effect":"allow","actions":["pgcreds:read"],"resource_type":"pgcreds","owner_matches_subject":true}
  policy get     -id ID
  policy update  -id ID [-priority N] [-enabled true|false]
                 [-not-before RFC3339] [-expires-at RFC3339]
                 [-clear-not-before] [-clear-expires-at]
  policy delete  -id ID

  tag list  -id UUID
  tag set   -id UUID -tags tag1,tag2,...
            Pass empty -tags "" to clear all tags.
`)
}
