// Package hetznerapi is a small typed client for the Hetzner unified API storage-box surface, used by the
// hub to provision the offsite tier. SPIKE 996d403 (authoritative, measured live): the base URL is
// https://api.hetzner.com/v1 — NOT api.hetzner.cloud (the classic Cloud API 404s for every storage-box
// route). Async writes return an action object; poll it to status "success".
//
// Provisioning depends on the CloudAPI interface (not *Client) so tests inject a fake — NO live Hetzner
// calls in CI. The bearer token is supplied by an injected func (read from an out-of-band secret, never a
// committed file); it is never logged.
package hetznerapi

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"time"
)

// DefaultBaseURL is the storage-box API base (spike §2).
const DefaultBaseURL = "https://api.hetzner.com/v1"

// Action is the async-write result the API returns; poll to status "success".
type Action struct {
	ID       int64  `json:"id"`
	Command  string `json:"command"`
	Status   string `json:"status"` // "running" | "success" | "error"
	Progress int    `json:"progress"`
	Error    *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

// AccessSettings mirrors the storage-box/subaccount access_settings object.
type AccessSettings struct {
	SSHEnabled          bool `json:"ssh_enabled"`
	ReachableExternally bool `json:"reachable_externally"`
	SambaEnabled        bool `json:"samba_enabled"`
	WebDAVEnabled       bool `json:"webdav_enabled"`
	Readonly            bool `json:"readonly"`
}

// Subaccount mirrors a storage-box subaccount (the non-secret fields; the API never returns a password).
type Subaccount struct {
	ID             int64             `json:"id"`
	StorageBox     int64             `json:"storage_box"`
	Username       string            `json:"username"`        // uXXXXXX-subN
	Server         string            `json:"server"`          // …-subN.your-storagebox.de
	HomeDirectory  string            `json:"home_directory"`
	AccessSettings AccessSettings    `json:"access_settings"`
	Labels         map[string]string `json:"labels"`
}

// StorageBox mirrors a dedicated storage box (non-secret fields).
type StorageBox struct {
	ID       int64  `json:"id"`
	Username string `json:"username"` // uXXXXXX (empty until active)
	Name     string `json:"name"`
	Status   string `json:"status"` // initializing | active | …
	Server   string `json:"server"` // uXXXXXX.your-storagebox.de
	Labels   map[string]string `json:"labels"`

	// StorageBoxType + Stats added v0.64.0 (R-5 pool-box aggregate). Shape pinned by the Phase-0 probe
	// of the live pool box 611714 (2026-07-17): GET /storage_boxes/{id} returns a `storage_box_type`
	// object whose `size` is the CAPACITY in bytes (1099511627776 = 1 TiB for bx11), and a `stats` object
	// carrying total/data/snapshot usage in bytes. Additive — no existing field or method changed.
	StorageBoxType StorageBoxType  `json:"storage_box_type"`
	Stats          StorageBoxStats `json:"stats"`
}

// StorageBoxType is the box's plan; its Size is the CAPACITY in bytes (never use stats.size for capacity).
type StorageBoxType struct {
	ID          int64  `json:"id"`
	Name        string `json:"name"`        // e.g. "bx11"
	Description string `json:"description"` // e.g. "BX11"
	Size        int64  `json:"size"`        // capacity in BYTES
}

// StorageBoxStats is the box's live usage, all in BYTES (probe-confirmed): total, the data portion, and
// the snapshot portion (Size == SizeData + SizeSnapshots on the live box).
type StorageBoxStats struct {
	Size          int64 `json:"size"`           // total used bytes
	SizeData      int64 `json:"size_data"`      // data bytes
	SizeSnapshots int64 `json:"size_snapshots"` // snapshot bytes
}

// CreateSubaccountRequest — POST /storage_boxes/{box}/subaccounts. Password satisfies the 4-class policy.
type CreateSubaccountRequest struct {
	HomeDirectory  string            `json:"home_directory"`
	Password       string            `json:"password"`
	AccessSettings AccessSettings    `json:"access_settings"`
	Labels         map[string]string `json:"labels,omitempty"`
	Description    string            `json:"description,omitempty"`
}

// CreateBoxRequest — POST /storage_boxes.
type CreateBoxRequest struct {
	Name           string            `json:"name"`
	StorageBoxType string            `json:"storage_box_type"` // e.g. "bx11"
	Location       string            `json:"location"`         // e.g. "fsn1"
	Password       string            `json:"password"`
	AccessSettings AccessSettings    `json:"access_settings"`
	Labels         map[string]string `json:"labels,omitempty"`
}

// CloudAPI is the storage-box provisioning surface the hub depends on. A fake implements it in tests.
type CloudAPI interface {
	ListSubaccounts(ctx context.Context, boxID int64, labelSelector string) ([]Subaccount, error)
	GetSubaccount(ctx context.Context, boxID, subID int64) (Subaccount, error)
	CreateSubaccount(ctx context.Context, boxID int64, req CreateSubaccountRequest) (createdID int64, action Action, err error)
	ResetSubaccountPassword(ctx context.Context, boxID, subID int64, password string) (Action, error)
	UpdateSubaccountAccess(ctx context.Context, boxID, subID int64, as AccessSettings) (Action, error)
	DeleteSubaccount(ctx context.Context, boxID, subID int64) (Action, error)

	ListStorageBoxes(ctx context.Context, labelSelector string) ([]StorageBox, error)
	GetStorageBox(ctx context.Context, boxID int64) (StorageBox, error)
	CreateStorageBox(ctx context.Context, req CreateBoxRequest) (createdID int64, action Action, err error)
	ChangeType(ctx context.Context, boxID int64, boxType string) (Action, error)
	ResetBoxPassword(ctx context.Context, boxID int64, password string) (Action, error)
	DeleteStorageBox(ctx context.Context, boxID int64) (Action, error)

	// WaitAction polls the action to "success" (bounded); errors on "error" or timeout.
	WaitAction(ctx context.Context, action Action) error
}

// Client implements CloudAPI against the live API.
type Client struct {
	BaseURL   string
	Token     func() string // injected; read from an out-of-band secret; never logged
	HC        *http.Client
	PollEvery time.Duration
	PollMax   time.Duration
}

// NewClient builds a Client with sane defaults. token must return the bearer (from env/mounted secret).
func NewClient(token func() string) *Client {
	return &Client{
		BaseURL:   DefaultBaseURL,
		Token:     token,
		HC:        &http.Client{Timeout: 30 * time.Second},
		PollEvery: 3 * time.Second,
		PollMax:   3 * time.Minute,
	}
}

// apiError is the API's error envelope {error:{code,message,details}}.
type apiError struct {
	Error struct {
		Code    string          `json:"code"`
		Message string          `json:"message"`
		Details json.RawMessage `json:"details"`
	} `json:"error"`
}

func (e *apiError) String() string { return fmt.Sprintf("%s: %s", e.Error.Code, e.Error.Message) }

// do performs a request and decodes the JSON body into out. On a non-2xx it returns a typed error carrying
// the API's error code/message. The token is set on the header only — never logged.
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
	var rdr io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return err
		}
		rdr = bytes.NewReader(b)
	}
	req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+c.Token())
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := c.HC.Do(req)
	if err != nil {
		return fmt.Errorf("hetznerapi: %s %s: %w", method, path, err)
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		var ae apiError
		if json.Unmarshal(raw, &ae) == nil && ae.Error.Code != "" {
			return fmt.Errorf("hetznerapi: %s %s: HTTP %d: %s", method, path, resp.StatusCode, ae.String())
		}
		return fmt.Errorf("hetznerapi: %s %s: HTTP %d", method, path, resp.StatusCode)
	}
	if out != nil && len(raw) > 0 {
		if err := json.Unmarshal(raw, out); err != nil {
			return fmt.Errorf("hetznerapi: %s %s: decode: %w", method, path, err)
		}
	}
	return nil
}

func labelQuery(labelSelector string) string {
	if labelSelector == "" {
		return ""
	}
	return "?label_selector=" + url.QueryEscape(labelSelector)
}

// --- subaccounts ---

func (c *Client) ListSubaccounts(ctx context.Context, boxID int64, labelSelector string) ([]Subaccount, error) {
	var out struct {
		Subaccounts []Subaccount `json:"subaccounts"`
	}
	err := c.do(ctx, http.MethodGet, fmt.Sprintf("/storage_boxes/%d/subaccounts%s", boxID, labelQuery(labelSelector)), nil, &out)
	return out.Subaccounts, err
}

func (c *Client) GetSubaccount(ctx context.Context, boxID, subID int64) (Subaccount, error) {
	var out struct {
		Subaccount Subaccount `json:"subaccount"`
	}
	err := c.do(ctx, http.MethodGet, fmt.Sprintf("/storage_boxes/%d/subaccounts/%d", boxID, subID), nil, &out)
	return out.Subaccount, err
}

func (c *Client) CreateSubaccount(ctx context.Context, boxID int64, req CreateSubaccountRequest) (int64, Action, error) {
	var out struct {
		Action     Action `json:"action"`
		Subaccount struct {
			ID int64 `json:"id"`
		} `json:"subaccount"`
	}
	err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/subaccounts", boxID), req, &out)
	return out.Subaccount.ID, out.Action, err
}

func (c *Client) ResetSubaccountPassword(ctx context.Context, boxID, subID int64, password string) (Action, error) {
	var out struct {
		Action Action `json:"action"`
	}
	err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/subaccounts/%d/actions/reset_subaccount_password", boxID, subID), map[string]string{"password": password}, &out)
	return out.Action, err
}

func (c *Client) UpdateSubaccountAccess(ctx context.Context, boxID, subID int64, as AccessSettings) (Action, error) {
	var out struct {
		Action Action `json:"action"`
	}
	err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/subaccounts/%d/actions/update_access_settings", boxID, subID), as, &out)
	return out.Action, err
}

func (c *Client) DeleteSubaccount(ctx context.Context, boxID, subID int64) (Action, error) {
	var out struct {
		Action Action `json:"action"`
	}
	err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/storage_boxes/%d/subaccounts/%d", boxID, subID), nil, &out)
	return out.Action, err
}

// --- storage boxes ---

func (c *Client) ListStorageBoxes(ctx context.Context, labelSelector string) ([]StorageBox, error) {
	var out struct {
		StorageBoxes []StorageBox `json:"storage_boxes"`
	}
	err := c.do(ctx, http.MethodGet, "/storage_boxes"+labelQuery(labelSelector), nil, &out)
	return out.StorageBoxes, err
}

func (c *Client) GetStorageBox(ctx context.Context, boxID int64) (StorageBox, error) {
	var out struct {
		StorageBox StorageBox `json:"storage_box"`
	}
	err := c.do(ctx, http.MethodGet, fmt.Sprintf("/storage_boxes/%d", boxID), nil, &out)
	return out.StorageBox, err
}

func (c *Client) CreateStorageBox(ctx context.Context, req CreateBoxRequest) (int64, Action, error) {
	var out struct {
		Action     Action `json:"action"`
		StorageBox struct {
			ID int64 `json:"id"`
		} `json:"storage_box"`
	}
	err := c.do(ctx, http.MethodPost, "/storage_boxes", req, &out)
	return out.StorageBox.ID, out.Action, err
}

func (c *Client) ChangeType(ctx context.Context, boxID int64, boxType string) (Action, error) {
	var out struct {
		Action Action `json:"action"`
	}
	err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/actions/change_type", boxID), map[string]string{"storage_box_type": boxType}, &out)
	return out.Action, err
}

// ResetBoxPassword resets a dedicated box's (main-account) password. The password value is only ever in
// the request body — never logged by the caller (F4 re-issue).
func (c *Client) ResetBoxPassword(ctx context.Context, boxID int64, password string) (Action, error) {
	var out struct {
		Action Action `json:"action"`
	}
	err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/actions/reset_password", boxID), map[string]string{"password": password}, &out)
	return out.Action, err
}

func (c *Client) DeleteStorageBox(ctx context.Context, boxID int64) (Action, error) {
	var out struct {
		Action Action `json:"action"`
	}
	err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/storage_boxes/%d", boxID), nil, &out)
	return out.Action, err
}

// WaitAction polls the action to "success" (bounded by PollMax). An already-terminal action returns
// immediately. It NEVER assumes create == ready.
func (c *Client) WaitAction(ctx context.Context, action Action) error {
	if s := action.Status; s == "success" {
		return nil
	} else if s == "error" {
		return actionErr(action)
	}
	deadline := time.Now().Add(c.PollMax)
	for {
		var out struct {
			Action Action `json:"action"`
		}
		if err := c.do(ctx, http.MethodGet, fmt.Sprintf("/storage_boxes/actions/%d", action.ID), nil, &out); err != nil {
			return err
		}
		switch out.Action.Status {
		case "success":
			return nil
		case "error":
			return actionErr(out.Action)
		}
		if time.Now().After(deadline) {
			return fmt.Errorf("hetznerapi: action %d (%s) did not reach success within %s", action.ID, action.Command, c.PollMax)
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(c.PollEvery):
		}
	}
}

func actionErr(a Action) error {
	if a.Error != nil {
		return fmt.Errorf("hetznerapi: action %d (%s) failed: %s: %s", a.ID, a.Command, a.Error.Code, a.Error.Message)
	}
	return fmt.Errorf("hetznerapi: action %d (%s) failed", a.ID, a.Command)
}
