package main

// panelctl admin: out-of-band repair commands that drive the controller's
// /admin/v1/ HTTP surface (NOT the gRPC Panel service used by every other
// command in this CLI). Auth is loopback-bypass on the controller host or
// X-Panel-Admin-Token over the wire — see admin.go in the controller for
// the server-side pair.
//
// Token resolution order, used by every admin subcommand:
//   1. --admin-token flag value (if non-empty)
//   2. $PANEL_ADMIN_TOKEN environment variable
//   3. <data-dir>/admin-token file (default data dir: ./data)
//
// The empty-token case is allowed: when running on the controller host
// itself, the request reaches the admin mux over loopback and the
// server bypasses the token check.

import (
	"bytes"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"
)

const (
	defaultAdminHTTP    = "http://localhost:8080"
	adminTokenFilename  = "admin-token"
	adminAuthHeaderName = "X-Panel-Admin-Token"
)

func adminUsage() {
	fmt.Fprintln(os.Stderr, `panelctl admin — out-of-band repair commands.

Usage:
  panelctl admin [flags] token-show
  panelctl admin [flags] status
  panelctl admin [flags] instances
  panelctl admin [flags] ark-rebind <instance-id>
  panelctl admin [flags] set-config <instance-id> KEY=VALUE [KEY=VALUE...]
  panelctl admin [flags] del-config <instance-id> KEY [KEY...]
  panelctl admin [flags] recreate   <instance-id> [--mount CONTAINER_PATH=HOST_PATH ...]

Flags:
  --http URL         Controller HTTP base URL (default `+defaultAdminHTTP+`, prod: http://localhost:8180)
                     Or set PANEL_HTTP env var.
  --admin-token TOK  Admin token. Defaults to $PANEL_ADMIN_TOKEN or <data-dir>/admin-token.
                     Not required when calling from the controller host (loopback bypass).
  --data-dir DIR     Where to look for admin-token file (default ./data; prod: /home/refuge/panel/data).
                     Or set PANEL_DATA_DIR env var.

Notes:
  set-config / del-config / recreate trigger a stop→delete-preserve→
  recreate→start dance. ARK cluster mount overrides are preserved
  automatically. Pass --mount to recreate to override the mount map.`)
}

// cmdAdmin is the entrypoint dispatched from main.go's switch.
func cmdAdmin(args []string) {
	fs := flag.NewFlagSet("panelctl admin", flag.ExitOnError)
	httpURL := fs.String("http", envOrDefault("PANEL_HTTP", defaultAdminHTTP), "controller HTTP base URL")
	tokenFlag := fs.String("admin-token", os.Getenv("PANEL_ADMIN_TOKEN"), "admin token (or use PANEL_ADMIN_TOKEN, or read from data-dir)")
	dataDir := fs.String("data-dir", envOrDefault("PANEL_DATA_DIR", "./data"), "data directory containing admin-token")
	fs.Usage = adminUsage
	if err := fs.Parse(args); err != nil {
		os.Exit(2)
	}
	rest := fs.Args()
	if len(rest) < 1 {
		adminUsage()
		os.Exit(2)
	}

	// Resolve the token unless the user explicitly passed --admin-token=.
	// The empty result is fine — server-side loopback bypass covers it.
	token := *tokenFlag
	if token == "" {
		token = readTokenFile(*dataDir)
	}

	switch rest[0] {
	case "token-show":
		adminTokenShow(*dataDir)
	case "status", "ping", "health":
		adminStatus(*httpURL, token)
	case "instances", "ls":
		adminInstances(*httpURL, token)
	case "ark-rebind":
		if len(rest) < 2 {
			die("usage: panelctl admin ark-rebind <instance-id>")
		}
		adminArkRebind(*httpURL, token, rest[1])
	case "set-config":
		if len(rest) < 3 {
			die("usage: panelctl admin set-config <instance-id> KEY=VALUE [KEY=VALUE...]")
		}
		adminSetConfig(*httpURL, token, rest[1], rest[2:])
	case "del-config":
		if len(rest) < 3 {
			die("usage: panelctl admin del-config <instance-id> KEY [KEY...]")
		}
		adminDelConfig(*httpURL, token, rest[1], rest[2:])
	case "recreate":
		if len(rest) < 2 {
			die("usage: panelctl admin recreate <instance-id> [--mount CONTAINER=HOST ...]")
		}
		adminRecreate(*httpURL, token, rest[1], rest[2:])
	default:
		adminUsage()
		os.Exit(2)
	}
}

// ---- handlers ----

func adminTokenShow(dataDir string) {
	tok := readTokenFile(dataDir)
	if tok == "" {
		fmt.Fprintf(os.Stderr, "panelctl: no admin-token file found at %s\n", filepath.Join(dataDir, adminTokenFilename))
		fmt.Fprintln(os.Stderr, "  (controller writes this on first start; check $PANEL_DATA_DIR)")
		os.Exit(1)
	}
	fmt.Println(tok)
}

func adminStatus(httpURL, token string) {
	body, status := adminGET(httpURL, "/admin/v1/health", token)
	if status != http.StatusOK {
		fmt.Fprintf(os.Stderr, "health: HTTP %d\n%s\n", status, string(body))
		os.Exit(1)
	}
	prettyPrintJSON(body)
}

func adminInstances(httpURL, token string) {
	body, status := adminGET(httpURL, "/admin/v1/instances", token)
	if status != http.StatusOK {
		fmt.Fprintf(os.Stderr, "instances: HTTP %d\n%s\n", status, string(body))
		os.Exit(1)
	}
	var resp struct {
		Instances []struct {
			InstanceID    string            `json:"instance_id"`
			AgentID       string            `json:"agent_id"`
			ModuleID      string            `json:"module_id"`
			Status        string            `json:"status"`
			ConfigValues  map[string]string `json:"config_values"`
			AssignedPorts map[string]uint32 `json:"assigned_ports"`
			ARKClusterID  string            `json:"ark_cluster_id"`
		} `json:"instances"`
	}
	if err := json.Unmarshal(body, &resp); err != nil {
		fmt.Fprintf(os.Stderr, "decode: %v\n%s\n", err, string(body))
		os.Exit(1)
	}
	if len(resp.Instances) == 0 {
		fmt.Println("(no instances)")
		return
	}
	fmt.Printf("%-20s  %-18s  %-14s  %-10s  %-12s  %s\n",
		"INSTANCE_ID", "AGENT_ID", "MODULE", "STATUS", "CFG_KEYS", "CLUSTER")
	for _, i := range resp.Instances {
		fmt.Printf("%-20s  %-18s  %-14s  %-10s  %-12d  %s\n",
			i.InstanceID, i.AgentID, i.ModuleID, i.Status, len(i.ConfigValues), i.ARKClusterID)
	}
}

func adminArkRebind(httpURL, token, instanceID string) {
	body, status := adminPOST(httpURL, "/admin/v1/instances/"+instanceID+"/ark-cluster-rebind", token, nil)
	if status != http.StatusOK {
		fmt.Fprintf(os.Stderr, "ark-rebind: HTTP %d\n%s\n", status, string(body))
		os.Exit(1)
	}
	prettyPrintJSON(body)
}

func adminSetConfig(httpURL, token, instanceID string, kvs []string) {
	set := map[string]string{}
	for _, kv := range kvs {
		eq := strings.IndexByte(kv, '=')
		if eq <= 0 {
			die("malformed KEY=VALUE: %q (must have a non-empty key before '=')", kv)
		}
		set[kv[:eq]] = kv[eq+1:]
	}
	payload := map[string]any{"set": set}
	body, status := adminPOST(httpURL, "/admin/v1/instances/"+instanceID+"/config", token, payload)
	if status != http.StatusOK {
		fmt.Fprintf(os.Stderr, "set-config: HTTP %d\n%s\n", status, string(body))
		os.Exit(1)
	}
	prettyPrintJSON(body)
}

func adminDelConfig(httpURL, token, instanceID string, keys []string) {
	payload := map[string]any{"delete": keys}
	body, status := adminPOST(httpURL, "/admin/v1/instances/"+instanceID+"/config", token, payload)
	if status != http.StatusOK {
		fmt.Fprintf(os.Stderr, "del-config: HTTP %d\n%s\n", status, string(body))
		os.Exit(1)
	}
	prettyPrintJSON(body)
}

func adminRecreate(httpURL, token, instanceID string, args []string) {
	mounts := map[string]string{}
	hasMount := false
	for i := 0; i < len(args); i++ {
		a := args[i]
		switch {
		case a == "--mount":
			if i+1 >= len(args) {
				die("--mount requires CONTAINER_PATH=HOST_PATH")
			}
			i++
			parseMountInto(args[i], mounts)
			hasMount = true
		case strings.HasPrefix(a, "--mount="):
			parseMountInto(a[len("--mount="):], mounts)
			hasMount = true
		default:
			die("unknown arg %q to recreate", a)
		}
	}
	payload := map[string]any{}
	if hasMount {
		payload["mount_overrides"] = mounts
	}
	body, status := adminPOST(httpURL, "/admin/v1/instances/"+instanceID+"/recreate", token, payload)
	if status != http.StatusOK {
		fmt.Fprintf(os.Stderr, "recreate: HTTP %d\n%s\n", status, string(body))
		os.Exit(1)
	}
	prettyPrintJSON(body)
}

func parseMountInto(spec string, out map[string]string) {
	eq := strings.IndexByte(spec, '=')
	if eq <= 0 {
		die("malformed --mount %q (expected CONTAINER_PATH=HOST_PATH)", spec)
	}
	out[spec[:eq]] = spec[eq+1:]
}

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

func adminGET(httpURL, path, token string) ([]byte, int) {
	req, err := http.NewRequest(http.MethodGet, strings.TrimRight(httpURL, "/")+path, nil)
	if err != nil {
		die("request: %v", err)
	}
	return doAdminRequest(req, token)
}

func adminPOST(httpURL, path, token string, payload any) ([]byte, int) {
	var body io.Reader
	if payload != nil {
		b, err := json.Marshal(payload)
		if err != nil {
			die("marshal: %v", err)
		}
		body = bytes.NewReader(b)
	}
	req, err := http.NewRequest(http.MethodPost, strings.TrimRight(httpURL, "/")+path, body)
	if err != nil {
		die("request: %v", err)
	}
	if payload != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	return doAdminRequest(req, token)
}

func doAdminRequest(req *http.Request, token string) ([]byte, int) {
	if token != "" {
		req.Header.Set(adminAuthHeaderName, token)
	}
	client := &http.Client{Timeout: 4 * time.Minute}
	resp, err := client.Do(req)
	if err != nil {
		die("http: %v", err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		die("read body: %v", err)
	}
	return body, resp.StatusCode
}

// ---- misc ----

func readTokenFile(dataDir string) string {
	b, err := os.ReadFile(filepath.Join(dataDir, adminTokenFilename))
	if err != nil {
		if !errors.Is(err, os.ErrNotExist) {
			fmt.Fprintf(os.Stderr, "panelctl: warn: read admin-token: %v\n", err)
		}
		return ""
	}
	return strings.TrimSpace(string(b))
}

func envOrDefault(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

func prettyPrintJSON(body []byte) {
	var v any
	if err := json.Unmarshal(body, &v); err != nil {
		fmt.Println(string(body))
		return
	}
	out, err := json.MarshalIndent(v, "", "  ")
	if err != nil {
		fmt.Println(string(body))
		return
	}
	fmt.Println(string(out))
}
