// warmblyctl is the CLI for a Warmbly instance. It has two halves with two
// trust models, and the split is deliberate:
//
// The operator commands (status, setup-link, user, org) talk to the database
// directly, so they keep working when signing in does not. Their authorization
// is container or host access, the same trust model as Sentry's createuser,
// Gitea's `admin user create` and authentik's `ak changepassword`. They read
// PRIMARY_DB and REDIS from the environment, which is why running them inside
// the backend container is the documented path.
//
// The API commands (api, campaign, contact, mailbox, inbox, ...) are an HTTP
// client over the public REST API, authorized by an API key, so they drive any
// instance the caller can reach, including the hosted service. They exist so
// agents and scripts can operate the product itself: everything they can do is
// bounded by the key's scopes. The CLI must never SERVE HTTP; being a client
// of the already-gated public API adds no new surface.
//
//	docker compose -p warmbly exec backend warmblyctl status
//	WARMBLY_API_KEY=wmbly_... warmblyctl campaign list
package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"os/signal"
	"strings"
)

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()

	args := os.Args[1:]
	if len(args) == 0 {
		usage(os.Stderr)
		os.Exit(2)
	}

	if err := dispatch(ctx, args); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			return
		}
		// A failing check has already printed itself; repeating it as an error
		// line would bury the findings under the tool's own noise.
		if errors.Is(err, errChecksFailed) {
			os.Exit(1)
		}
		fmt.Fprintf(os.Stderr, "%s\n", err)
		os.Exit(1)
	}
}

func dispatch(ctx context.Context, args []string) error {
	switch args[0] {
	case "help", "-h", "--help":
		usage(os.Stdout)
		return nil
	case "status":
		return runStatus(ctx, args[1:])
	case "setup-link":
		return runSetupLink(ctx, args[1:])
	case "hash-password":
		return runHashPassword(ctx, args[1:])
	case "user":
		return runUser(ctx, args[1:])
	case "org":
		return runOrg(ctx, args[1:])
	case "backup":
		return runBackup(ctx, args[1:])
	case "restore":
		return runRestore(ctx, args[1:])
	case "api":
		return runAPI(ctx, args[1:])
	}
	if _, ok := apiFamilies[args[0]]; ok {
		return runAPIResource(ctx, args[0], args[1:])
	}
	return fmt.Errorf("unknown command %q. Run `warmblyctl --help` for the full list.", args[0])
}

// command is one help entry: what it does and one invocation worth copying.
// The example is the whole command line, because the piped ones do not survive
// having a `docker compose exec` prefix bolted on.
type command struct {
	name    string
	summary string
	example string
}

const composeExec = "docker compose -p warmbly exec backend warmblyctl "

var commands = []command{
	{"status", "Show accounts, admins, registration and mail state, how to get in, and what is wrong", composeExec + "status"},
	{"setup-link", "Print a fresh single-use link that claims an instance with no accounts", composeExec + "setup-link"},
	{"user create", "Create a user, an organization and a trial, optionally a platform admin", composeExec + "user create --email you@example.com --admin"},
	{"user list", "List accounts; --admin answers whether any platform admin survives", composeExec + "user list --admin"},
	{"user reset-password", "Print a one-time reset link, or set the password from stdin", composeExec + "user reset-password --email you@example.com"},
	{"user grant-admin", "Give an account a platform admin role", composeExec + "user grant-admin --email you@example.com --role super"},
	{"user revoke-admin", "Take platform admin away from an account", composeExec + "user revoke-admin --email old@example.com"},
	{"user disable-2fa", "Clear an account's TOTP enrollment after a lost authenticator", composeExec + "user disable-2fa --email you@example.com"},
	{"hash-password", "Print an argon2 hash for WARMBLY_BOOTSTRAP_PASSWORD_HASH", "printf '%s' 'your-password' | docker compose -p warmbly exec -T backend warmblyctl hash-password"},
	{"backup", "Write the whole instance (database, blobs, keys) to one restorable bundle", composeExec + "backup --out /data/blobs/warmbly-backup.tar.gz"},
	{"restore", "Restore a bundle onto this instance, replacing everything on it", composeExec + "restore --file /data/blobs/warmbly-backup.tar.gz"},
	{"org list", "List the workspaces on this instance with their id, owner, and size", composeExec + "org list"},
	{"org export", "Write a whole workspace to a portable archive file", composeExec + "org export --org you@example.com --out /tmp/workspace.warmbly.zip"},
	{"org import", "Apply an archive to a workspace on this instance", composeExec + "org import --org you@example.com --file /tmp/workspace.warmbly.zip"},
}

func usage(w *os.File) {
	fmt.Fprint(w, `warmblyctl is the CLI for a Warmbly instance. The operator commands read and
write the database directly, so they work when the sign-in page does not. The
API commands drive a running instance over its public REST API with an API
key, so they work against any instance you hold a key for, hosted included.

Usage:
  warmblyctl <command> [flags]

Operator commands (run where PRIMARY_DB is set, normally the backend container):
`)
	for _, c := range commands {
		fmt.Fprintf(w, "  %-20s %s\n", c.name, c.summary)
	}

	fmt.Fprint(w, "\nAPI commands (need WARMBLY_API_KEY; each family lists its own subcommands):\n")
	for _, f := range apiFamilyOrder {
		fmt.Fprintf(w, "  %-20s %s\n", f, apiFamilies[f])
	}
	fmt.Fprintf(w, "  %-20s %s\n", "api", "Raw passthrough: any method, any /v1 path")

	fmt.Fprint(w, "\nExamples:\n")
	for _, c := range commands {
		fmt.Fprintf(w, "  %s\n", c.example)
	}
	fmt.Fprint(w, `  warmblyctl campaign list
  warmblyctl campaign start --id <uuid>
  warmblyctl api get "/campaigns?limit=10"
`)

	_, _ = io.WriteString(w, `
Anything piped into a container needs exec -T, which is what turns the TTY off.
Anything that prompts needs a TTY, so run it without -T.

Environment:
  PRIMARY_DB       Postgres connection string. Every operator command except hash-password needs it.
  REDIS            Redis URL. setup-link needs it, and reset-password needs it to mint a link.
  APP_URL          Base URL every printed link is built from.
  WARMBLY_API_KEY  API key (wmbly_...) for the API commands.
  WARMBLY_API_URL  API base URL for the API commands. Defaults to this instance's
                   API_PUBLIC_URL, then the hosted service.

  backup and restore read the same crypto settings as org export/import, plus
  BLOB_FS_ROOT for the blob tree. Run them inside the backend container, which
  is also where pg_dump and psql live.

  org export and org import additionally read the instance's crypto settings,
  because a workspace's sealed values have to be opened on the way out and
  re-sealed on the way in: KMS_PROVIDER (with KMS_LOCAL_MASTER_KEY or the AWS
  settings) and CREDENTIALS_ENCRYPTION_KEY. Run them inside the backend
  container, where those are already set.

Exit status:
  status exits non-zero when a check is at error severity, so it works as a
  post-deploy gate. Use status --quiet from cron: it prints the checks alone,
  and nothing at all when none fire. status --json always exits 0, because it
  doubles as a liveness probe; read .summary.error there instead.

Run `+"`warmblyctl <command> --help`"+` for one command's flags.
`)
}

// newFlagSet gives every subcommand the same help shape: its flags, then one
// example, because a flag list alone never answers "what do I type". The text
// comes from the commands table so there is one place to keep it honest.
func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	c := lookupCommand(name)
	fs.Usage = func() {
		fmt.Fprintf(os.Stderr, "%s.\n\nUsage:\n  warmblyctl %s [flags]\n\nFlags:\n", c.summary, name)
		fs.PrintDefaults()
		fmt.Fprintf(os.Stderr, "\nExample:\n  %s\n", c.example)
	}
	return fs
}

func lookupCommand(name string) command {
	for _, c := range commands {
		if c.name == name {
			return c
		}
	}
	return command{name: name, summary: "warmblyctl " + name}
}

// noExtraArgs rejects stray positional arguments so a typo like
// `user create you@example.com` fails loudly instead of creating nothing.
func noExtraArgs(fs *flag.FlagSet) error {
	if fs.NArg() == 0 {
		return nil
	}
	return fmt.Errorf("unexpected argument %q. Every value goes in a flag, for example --email you@example.com.", fs.Arg(0))
}

// warn writes an operational note that is not a failure.
func warn(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "Warning: "+format+"\n", args...)
}

// indent renders a block of next steps under a heading.
func printSteps(heading string, steps []string) {
	if len(steps) == 0 {
		return
	}
	fmt.Printf("\n%s\n", heading)
	for _, s := range steps {
		for _, line := range strings.Split(s, "\n") {
			fmt.Printf("  %s\n", line)
		}
	}
}
