// Package services — SupabaseAdminService — thin HTTP client for the
// privileged Supabase Admin API endpoints.
//
// t-paliad-223 Slice B (#49) — the new "Add User" path on /admin/team needs
// to create an auth.users row before inserting paliad.users (paliad.users.id
// is FK-constrained to auth.users.id). The Supabase JS / Go client library
// would be overkill for the three calls we actually make; this file is
// ~150 LoC of plain net/http instead.
//
// Only three Admin-API calls are exercised here:
//
//   - POST   {SUPABASE_URL}/auth/v1/admin/users
//     Create an auth.users row with email_confirm=true so the user can log
//     in via a recovery link without going through the email-confirm step.
//
//   - POST   {SUPABASE_URL}/auth/v1/admin/generate_link
//     Mint a recovery link for the new user; paliad emails it via the
//     existing MailService template (NOT Supabase's default mail) so the
//     welcome message stays paliad-branded.
//
//   - DELETE {SUPABASE_URL}/auth/v1/admin/users/{id}
//     Best-effort rollback when the paliad.users insert fails after the
//     auth.users row has been created. Failure here just leaves an
//     unonboarded auth.users row that "Onboard existing" can recover.
//
// All requests carry the service-role key in BOTH the `apikey` header AND
// the `Authorization: Bearer` header — Supabase's PostgREST gateway checks
// the former, the auth admin handlers check the latter.
//
// SECURITY: SUPABASE_SERVICE_ROLE_KEY is one of the most-privileged
// credentials in the deploy. It must NEVER be sent to the browser or
// logged. Storage is Dokploy secret, age-encrypted at rest.
package services

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/google/uuid"
)

// Sentinel errors. Handlers map these to HTTP status codes.
var (
	// ErrSupabaseAdminUnavailable signals SUPABASE_SERVICE_ROLE_KEY is unset.
	// Handlers map to 503 — the Add-User path is the only feature that
	// requires it; everything else keeps working.
	ErrSupabaseAdminUnavailable = errors.New("supabase admin api unavailable (SUPABASE_SERVICE_ROLE_KEY not set)")
	// ErrSupabaseEmailExists is returned by CreateAuthUser when the email
	// already exists in auth.users. Handlers map to 409 with a nudge to
	// use "Onboard existing".
	ErrSupabaseEmailExists = errors.New("auth.users row already exists for this email")
)

// SupabaseAdminClient is the thin HTTP client. Constructed once at server
// boot; the embedded *http.Client is reused for connection pooling.
//
// Enabled() reports whether SUPABASE_SERVICE_ROLE_KEY is configured. When
// it isn't, every call returns ErrSupabaseAdminUnavailable so the rest of
// the boot path stays runnable for deployments that don't need Add-User.
type SupabaseAdminClient struct {
	baseURL    string
	apiKey     string
	httpClient *http.Client
}

// NewSupabaseAdminClient wires the client. supabaseURL is required (already
// validated at boot for the anon-key flow); serviceRoleKey may be empty.
//
// Timeout is 10s — Supabase Admin API calls are normally sub-second; 10s
// is forgiving enough for cold starts on a slow network but short enough
// that a hung call doesn't block the admin UI indefinitely.
func NewSupabaseAdminClient(supabaseURL, serviceRoleKey string) *SupabaseAdminClient {
	return &SupabaseAdminClient{
		baseURL:    strings.TrimRight(supabaseURL, "/"),
		apiKey:     strings.TrimSpace(serviceRoleKey),
		httpClient: &http.Client{Timeout: 10 * time.Second},
	}
}

// Enabled reports whether the client has a service-role key to use.
func (c *SupabaseAdminClient) Enabled() bool {
	return c != nil && c.apiKey != ""
}

// CreateAuthUser creates an auth.users row with email_confirm=true and no
// password (the new user signs in via the recovery link emailed later).
// Returns the new auth.users.id.
//
// 422 from Supabase typically means "email already exists" — mapped to
// ErrSupabaseEmailExists so the handler nudges the admin to "Onboard
// existing" instead.
func (c *SupabaseAdminClient) CreateAuthUser(ctx context.Context, email string) (uuid.UUID, error) {
	if !c.Enabled() {
		return uuid.Nil, ErrSupabaseAdminUnavailable
	}
	body := map[string]any{
		"email":         strings.ToLower(strings.TrimSpace(email)),
		"email_confirm": true,
	}
	var resp struct {
		ID  string `json:"id"`
		Msg string `json:"msg,omitempty"`
	}
	status, raw, err := c.do(ctx, "POST", "/auth/v1/admin/users", body, &resp)
	if err != nil {
		return uuid.Nil, err
	}
	if status == http.StatusUnprocessableEntity || status == http.StatusConflict {
		// Supabase returns 422 (or sometimes 400 with "already registered"
		// in the body) when the email is taken. Lower-case-match the
		// substring so we catch both casings.
		if strings.Contains(strings.ToLower(string(raw)), "already") {
			return uuid.Nil, ErrSupabaseEmailExists
		}
		return uuid.Nil, fmt.Errorf("supabase admin create user: status=%d body=%s", status, string(raw))
	}
	if status < 200 || status >= 300 {
		return uuid.Nil, fmt.Errorf("supabase admin create user: status=%d body=%s", status, string(raw))
	}
	id, err := uuid.Parse(resp.ID)
	if err != nil {
		return uuid.Nil, fmt.Errorf("supabase admin create user: parse id %q: %w", resp.ID, err)
	}
	return id, nil
}

// GenerateRecoveryLink mints a one-time recovery link for an existing
// auth.users row. The action_link is what we email; clicking it lands the
// user on Supabase's password-reset page (which redirects to paliad.de
// after the user picks a password).
//
// The link type is "recovery" rather than "magiclink" so the user is forced
// to set a password — paliad doesn't support passwordless sign-in today.
func (c *SupabaseAdminClient) GenerateRecoveryLink(ctx context.Context, email string) (string, error) {
	if !c.Enabled() {
		return "", ErrSupabaseAdminUnavailable
	}
	body := map[string]any{
		"type":  "recovery",
		"email": strings.ToLower(strings.TrimSpace(email)),
	}
	var resp struct {
		ActionLink string `json:"action_link"`
		Properties struct {
			ActionLink string `json:"action_link"`
		} `json:"properties"`
	}
	status, raw, err := c.do(ctx, "POST", "/auth/v1/admin/generate_link", body, &resp)
	if err != nil {
		return "", err
	}
	if status < 200 || status >= 300 {
		return "", fmt.Errorf("supabase admin generate_link: status=%d body=%s", status, string(raw))
	}
	// Supabase has historically returned the link in both shapes (top-level
	// and nested under properties). Accept either.
	if resp.ActionLink != "" {
		return resp.ActionLink, nil
	}
	if resp.Properties.ActionLink != "" {
		return resp.Properties.ActionLink, nil
	}
	return "", fmt.Errorf("supabase admin generate_link: response missing action_link: %s", string(raw))
}

// DeleteAuthUser removes an auth.users row by id. Best-effort rollback
// after the paliad.users insert has failed. A failure here is logged but
// doesn't propagate to the caller — the row can be cleaned up later via
// "Onboard existing" or the admin UI.
func (c *SupabaseAdminClient) DeleteAuthUser(ctx context.Context, id uuid.UUID) error {
	if !c.Enabled() {
		return ErrSupabaseAdminUnavailable
	}
	status, raw, err := c.do(ctx, "DELETE", "/auth/v1/admin/users/"+id.String(), nil, nil)
	if err != nil {
		return err
	}
	if status < 200 || status >= 300 {
		return fmt.Errorf("supabase admin delete user: status=%d body=%s", status, string(raw))
	}
	return nil
}

// do is the shared request helper. Returns (status, raw_body, err). When
// `out` is non-nil and the response is 2xx with a JSON body, decodes into
// it; raw_body is still returned so the caller can inspect error responses.
func (c *SupabaseAdminClient) do(ctx context.Context, method, path string, payload any, out any) (int, []byte, error) {
	var rdr io.Reader
	if payload != nil {
		buf, err := json.Marshal(payload)
		if err != nil {
			return 0, nil, fmt.Errorf("marshal %s body: %w", path, err)
		}
		rdr = bytes.NewReader(buf)
	}
	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rdr)
	if err != nil {
		return 0, nil, fmt.Errorf("build %s request: %w", path, err)
	}
	if rdr != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	req.Header.Set("apikey", c.apiKey)
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Accept", "application/json")

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return 0, nil, fmt.Errorf("%s %s: %w", method, path, err)
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(resp.Body)
	if err != nil {
		return resp.StatusCode, nil, fmt.Errorf("read %s response: %w", path, err)
	}
	if out != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 && len(raw) > 0 {
		if err := json.Unmarshal(raw, out); err != nil {
			return resp.StatusCode, raw, fmt.Errorf("decode %s response: %w", path, err)
		}
	}
	return resp.StatusCode, raw, nil
}

// LoadSupabaseAdminClient reads SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY
// from the environment and returns a client. The key is optional — when
// unset the client still wires (so dependents don't panic on nil-deref)
// but every call short-circuits with ErrSupabaseAdminUnavailable so the
// server boot stays runnable.
func LoadSupabaseAdminClient() *SupabaseAdminClient {
	return NewSupabaseAdminClient(
		os.Getenv("SUPABASE_URL"),
		os.Getenv("SUPABASE_SERVICE_ROLE_KEY"),
	)
}
