// Package storage is a minimal S3-compatible client for RustFS, implemented with
// AWS Signature V4 over the standard library only (no external SDK — the build
// environment can't reach the Go module proxy). It's intentionally small: Put /
// Get / Delete / List cover everything the app needs (store generated media,
// proxy it back through /images, list for the admin gallery, prune by age).
//
// The surface mirrors what a thin wrapper over aws-sdk-go-v2 would expose, so it
// can be swapped for the official SDK later by reimplementing this one file.
package storage

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/xml"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"
)

const (
	region  = "us-east-1" // RustFS ignores the value but SigV4 requires a fixed one
	service = "s3"
)

type Client struct {
	endpoint string // e.g. http://154.9.26.140:9000 (no trailing slash)
	host     string // e.g. 154.9.26.140:9000
	bucket   string
	ak, sk   string
	http     *http.Client
}

// Object is one entry returned by List.
type Object struct {
	Key          string
	Size         int64
	LastModified time.Time
}

// New builds a client. endpoint must include the scheme (http:// or https://).
func New(endpoint, bucket, accessKey, secretKey string) *Client {
	endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
	host := endpoint
	if i := strings.Index(host, "://"); i >= 0 {
		host = host[i+3:]
	}
	return &Client{
		endpoint: endpoint,
		host:     host,
		bucket:   strings.TrimSpace(bucket),
		ak:       strings.TrimSpace(accessKey),
		sk:       strings.TrimSpace(secretKey),
		http:     &http.Client{Timeout: 60 * time.Second},
	}
}

// Configured reports whether the client has the minimum config to be usable.
func (c *Client) Configured() bool {
	return c != nil && c.endpoint != "" && c.bucket != "" && c.ak != "" && c.sk != ""
}

// PublicURL is the direct object URL (used only for reference/debugging — the app
// serves through the authenticated /images proxy, not this).
func (c *Client) PublicURL(key string) string {
	return c.endpoint + "/" + c.bucket + "/" + strings.TrimPrefix(key, "/")
}

// Put uploads body under key with the given content type.
func (c *Client) Put(ctx context.Context, key string, body []byte, contentType string) error {
	resp, err := c.do(ctx, http.MethodPut, c.bucket+"/"+key, nil, body, contentType, nil)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		return c.statusErr("put", key, resp)
	}
	return nil
}

// Get fetches key. The caller owns resp.Body (must Close it) and streams it. A
// non-empty rangeHeader is forwarded verbatim (for video seeking). Returns the
// raw *http.Response so headers/status can be passed through by the proxy.
func (c *Client) Get(ctx context.Context, key, rangeHeader string) (*http.Response, error) {
	extra := map[string]string{}
	if strings.TrimSpace(rangeHeader) != "" {
		extra["Range"] = rangeHeader
	}
	return c.do(ctx, http.MethodGet, c.bucket+"/"+key, nil, nil, "", extra)
}

// Delete removes key. A missing object is not an error.
func (c *Client) Delete(ctx context.Context, key string) error {
	resp, err := c.do(ctx, http.MethodDelete, c.bucket+"/"+key, nil, nil, "", nil)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
		return c.statusErr("delete", key, resp)
	}
	return nil
}

// List returns every object whose key starts with prefix (paginated internally).
func (c *Client) List(ctx context.Context, prefix string) ([]Object, error) {
	var out []Object
	token := ""
	for {
		q := map[string]string{"list-type": "2", "max-keys": "1000"}
		if prefix != "" {
			q["prefix"] = prefix
		}
		if token != "" {
			q["continuation-token"] = token
		}
		resp, err := c.do(ctx, http.MethodGet, c.bucket, q, nil, "", nil)
		if err != nil {
			return nil, err
		}
		data, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		if resp.StatusCode/100 != 2 {
			return nil, fmt.Errorf("rustfs list: status %d: %s", resp.StatusCode, truncate(data))
		}
		var parsed struct {
			Contents []struct {
				Key          string    `xml:"Key"`
				Size         int64     `xml:"Size"`
				LastModified time.Time `xml:"LastModified"`
			} `xml:"Contents"`
			IsTruncated           bool   `xml:"IsTruncated"`
			NextContinuationToken string `xml:"NextContinuationToken"`
		}
		if err := xml.Unmarshal(data, &parsed); err != nil {
			return nil, fmt.Errorf("rustfs list: parse: %w", err)
		}
		for _, it := range parsed.Contents {
			out = append(out, Object{Key: it.Key, Size: it.Size, LastModified: it.LastModified})
		}
		if !parsed.IsTruncated || parsed.NextContinuationToken == "" {
			break
		}
		token = parsed.NextContinuationToken
	}
	return out, nil
}

func (c *Client) statusErr(op, key string, resp *http.Response) error {
	data, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
	return fmt.Errorf("rustfs %s %q: status %d: %s", op, key, resp.StatusCode, truncate(data))
}

func truncate(b []byte) string {
	s := strings.TrimSpace(string(b))
	if len(s) > 300 {
		return s[:300]
	}
	return s
}

// do builds, signs (SigV4) and sends a request. resourcePath is the path after
// the host WITHOUT a leading slash (e.g. "bucket/dir/file.png" or "bucket").
func (c *Client) do(ctx context.Context, method, resourcePath string, query map[string]string, body []byte, contentType string, extraHeaders map[string]string) (*http.Response, error) {
	now := time.Now().UTC()
	amzDate := now.Format("20060102T150405Z")
	dateStamp := now.Format("20060102")

	canonicalURI := "/" + uriEncode(resourcePath, true)
	canonicalQuery := canonicalQueryString(query)

	payloadHash := hexSHA256(body)
	// Signed headers: always host + x-amz-content-sha256 + x-amz-date, plus
	// content-type on PUT. Range etc. are sent unsigned.
	signed := map[string]string{
		"host":                 c.host,
		"x-amz-content-sha256": payloadHash,
		"x-amz-date":           amzDate,
	}
	if strings.TrimSpace(contentType) != "" {
		signed["content-type"] = contentType
	}
	names := sortedKeys(signed)
	var canonHeaders strings.Builder
	for _, k := range names {
		canonHeaders.WriteString(k + ":" + signed[k] + "\n")
	}
	signedHeaders := strings.Join(names, ";")

	canonicalRequest := strings.Join([]string{
		method, canonicalURI, canonicalQuery, canonHeaders.String(), signedHeaders, payloadHash,
	}, "\n")

	scope := dateStamp + "/" + region + "/" + service + "/aws4_request"
	stringToSign := strings.Join([]string{
		"AWS4-HMAC-SHA256", amzDate, scope, hexSHA256([]byte(canonicalRequest)),
	}, "\n")
	signature := hex.EncodeToString(hmacSHA256(signingKey(c.sk, dateStamp), []byte(stringToSign)))
	auth := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
		c.ak, scope, signedHeaders, signature)

	url := c.endpoint + canonicalURI
	if canonicalQuery != "" {
		url += "?" + canonicalQuery
	}
	var rdr io.Reader
	if body != nil {
		rdr = bytes.NewReader(body)
	}
	req, err := http.NewRequestWithContext(ctx, method, url, rdr)
	if err != nil {
		return nil, err
	}
	req.Host = c.host
	req.Header.Set("Authorization", auth)
	req.Header.Set("x-amz-date", amzDate)
	req.Header.Set("x-amz-content-sha256", payloadHash)
	if ct := strings.TrimSpace(contentType); ct != "" {
		req.Header.Set("Content-Type", ct)
	}
	for k, v := range extraHeaders {
		req.Header.Set(k, v)
	}
	return c.http.Do(req)
}

// ---- SigV4 helpers ----

func hexSHA256(b []byte) string {
	h := sha256.Sum256(b)
	return hex.EncodeToString(h[:])
}

func hmacSHA256(key, msg []byte) []byte {
	h := hmac.New(sha256.New, key)
	h.Write(msg)
	return h.Sum(nil)
}

func signingKey(secret, dateStamp string) []byte {
	kDate := hmacSHA256([]byte("AWS4"+secret), []byte(dateStamp))
	kRegion := hmacSHA256(kDate, []byte(region))
	kService := hmacSHA256(kRegion, []byte(service))
	return hmacSHA256(kService, []byte("aws4_request"))
}

func sortedKeys(m map[string]string) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	// simple insertion sort (small n)
	for i := 1; i < len(out); i++ {
		for j := i; j > 0 && out[j-1] > out[j]; j-- {
			out[j-1], out[j] = out[j], out[j-1]
		}
	}
	return out
}

func canonicalQueryString(q map[string]string) string {
	if len(q) == 0 {
		return ""
	}
	keys := sortedKeys(q)
	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		parts = append(parts, uriEncode(k, false)+"="+uriEncode(q[k], false))
	}
	return strings.Join(parts, "&")
}

// uriEncode applies AWS's URI encoding rules. When keepSlash is true, '/' is left
// as-is (for object key paths); otherwise it's percent-encoded (for query parts).
func uriEncode(s string, keepSlash bool) string {
	var b strings.Builder
	for _, r := range []byte(s) {
		switch {
		case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'),
			r == '-', r == '_', r == '.', r == '~':
			b.WriteByte(r)
		case r == '/' && keepSlash:
			b.WriteByte('/')
		default:
			b.WriteString(fmt.Sprintf("%%%02X", r))
		}
	}
	return b.String()
}
