// Package pb is a small PocketBase REST client. The API Server is the only
// component that talks to PocketBase, so all database access funnels through
// here. It authenticates as a superuser and performs CRUD on collection records.
package pb

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

// Client is a concurrency-safe PocketBase REST client with auto re-auth.
//
// The target address and service-account credentials are guarded by mu because
// a superadmin can retarget them at runtime (Settings → PocketBase in the panel)
// while requests are in flight.
type Client struct {
	http *http.Client

	mu       sync.RWMutex
	baseURL  string
	email    string
	password string
	token    string
}

func New(baseURL, email, password string) *Client {
	return &Client{
		baseURL:  baseURL,
		email:    email,
		password: password,
		http:     &http.Client{Timeout: 15 * time.Second},
	}
}

// creds snapshots the connection under lock so a concurrent Reconfigure can't
// tear it mid-request.
func (c *Client) creds() (baseURL, email, password string) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.baseURL, c.email, c.password
}

// BaseURL returns the PocketBase address currently in use.
func (c *Client) BaseURL() string {
	baseURL, _, _ := c.creds()
	return baseURL
}

// Configured reports whether a service account has been supplied. Endpoints that
// need superuser access check this and return 503 when it is false.
func (c *Client) Configured() bool {
	_, email, password := c.creds()
	return email != "" && password != ""
}

// Reconfigure retargets the client at a new PocketBase and/or new credentials,
// invalidating any cached superuser token so the next call re-authenticates.
func (c *Client) Reconfigure(baseURL, email, password string) {
	c.mu.Lock()
	c.baseURL = baseURL
	c.email = email
	c.password = password
	c.token = ""
	c.mu.Unlock()
}

// APIError carries the HTTP status and body from a failed PocketBase call.
type APIError struct {
	Status int
	Body   string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("pocketbase: status %d: %s", e.Status, e.Body)
}

// Authenticate obtains a superuser token. It tries the PocketBase v0.23+
// (_superusers collection) endpoint first, then the legacy admins endpoint.
func (c *Client) Authenticate(ctx context.Context) error {
	baseURL, email, password := c.creds()
	body, _ := json.Marshal(map[string]string{
		"identity": email,
		"password": password,
	})

	endpoints := []string{
		"/api/collections/_superusers/auth-with-password",
		"/api/admins/auth-with-password",
	}

	var lastErr error
	for _, ep := range endpoints {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+ep, bytes.NewReader(body))
		if err != nil {
			return err
		}
		req.Header.Set("Content-Type", "application/json")

		resp, err := c.http.Do(req)
		if err != nil {
			return err
		}
		raw, _ := io.ReadAll(resp.Body)
		resp.Body.Close()

		if resp.StatusCode == http.StatusOK {
			var out struct {
				Token string `json:"token"`
			}
			if err := json.Unmarshal(raw, &out); err != nil {
				return err
			}
			c.mu.Lock()
			c.token = out.Token
			c.mu.Unlock()
			return nil
		}
		lastErr = &APIError{Status: resp.StatusCode, Body: string(raw)}
	}
	return fmt.Errorf("authentication failed: %w", lastErr)
}

func (c *Client) currentToken() string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.token
}

// ensureToken acquires a superuser token when none is cached yet, so that a
// superuser call never goes out unauthenticated.
//
// It matters because PocketBase answers a record read it will not allow with
// 404, not 401 — it hides the record rather than refusing the credentials. So a
// request sent with no Authorization header comes back "The requested resource
// wasn't found", the 401 retries below never fire, and the caller is told the
// record is missing when the truth is that this client never signed in. The
// cache is empty in exactly the cases that matter: a startup where Authenticate
// failed because PocketBase wasn't up yet (deliberately non-fatal), and a
// Reconfigure, which clears the token so the new credentials are used.
//
// With no service account configured there is nothing to acquire, so the call
// proceeds as before — the endpoints that need superuser access answer 503 on
// their own.
func (c *Client) ensureToken(ctx context.Context) error {
	if c.currentToken() != "" || !c.Configured() {
		return nil
	}
	return c.Authenticate(ctx)
}

// do executes a request, attaching the auth token — signing in first if there
// isn't one yet. On a 401 it re-authenticates once and retries.
func (c *Client) do(ctx context.Context, method, path string, payload any) ([]byte, error) {
	if err := c.ensureToken(ctx); err != nil {
		return nil, err
	}
	raw, status, err := c.attempt(ctx, method, path, payload)
	if err != nil {
		return nil, err
	}
	if status == http.StatusUnauthorized {
		if err := c.Authenticate(ctx); err != nil {
			return nil, err
		}
		raw, status, err = c.attempt(ctx, method, path, payload)
		if err != nil {
			return nil, err
		}
	}
	if status < 200 || status >= 300 {
		return nil, &APIError{Status: status, Body: string(raw)}
	}
	return raw, nil
}

func (c *Client) attempt(ctx context.Context, method, path string, payload any) ([]byte, int, error) {
	var reader io.Reader
	if payload != nil {
		b, err := json.Marshal(payload)
		if err != nil {
			return nil, 0, err
		}
		reader = bytes.NewReader(b)
	}

	req, err := http.NewRequestWithContext(ctx, method, c.BaseURL()+path, reader)
	if err != nil {
		return nil, 0, err
	}
	if payload != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	if tok := c.currentToken(); tok != "" {
		req.Header.Set("Authorization", tok)
	}

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, 0, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(resp.Body)
	return raw, resp.StatusCode, err
}

// Raw performs a superuser request and returns the upstream body and status
// WITHOUT translating a non-2xx into an error. Handlers that want to relay
// PocketBase's own validation errors to the client verbatim (user/organization
// management) use this; handlers that want Go errors use the typed CRUD helpers.
func (c *Client) Raw(ctx context.Context, method, path string, payload any) ([]byte, int, error) {
	if err := c.ensureToken(ctx); err != nil {
		return nil, 0, err
	}
	raw, status, err := c.attempt(ctx, method, path, payload)
	if err != nil {
		return nil, 0, err
	}
	if status == http.StatusUnauthorized {
		if err := c.Authenticate(ctx); err != nil {
			return nil, 0, err
		}
		return c.attempt(ctx, method, path, payload)
	}
	return raw, status, nil
}

// AuthRefresh validates an end user's auth token against PocketBase and returns
// the refreshed auth response body and status. Unlike the superuser calls this
// carries the *caller's* token, not the service account's — it is how the server
// resolves who a request belongs to.
func (c *Client) AuthRefresh(ctx context.Context, collection, token string) ([]byte, int, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		c.BaseURL()+"/api/collections/"+collection+"/auth-refresh", nil)
	if err != nil {
		return nil, 0, err
	}
	req.Header.Set("Authorization", token)

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, 0, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(resp.Body)
	return raw, resp.StatusCode, err
}

// LoginWithPassword forwards a login to PocketBase's auth-with-password and
// returns its response body and status untouched, so the caller can relay both
// (token + record) straight back to the client.
func (c *Client) LoginWithPassword(ctx context.Context, collection, identity, password string) ([]byte, int, error) {
	body, _ := json.Marshal(map[string]string{"identity": identity, "password": password})
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		c.BaseURL()+"/api/collections/"+collection+"/auth-with-password", bytes.NewReader(body))
	if err != nil {
		return nil, 0, err
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, 0, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(resp.Body)
	return raw, resp.StatusCode, err
}

// SuperuserAuth performs a one-off superuser auth-with-password against an
// arbitrary PocketBase and returns the HTTP status. It shares no state with any
// Client, so the settings endpoints can test a *candidate* connection before
// applying it. Both the v0.23+ (_superusers) and legacy (admins) endpoints are
// tried, matching Client.Authenticate.
func SuperuserAuth(ctx context.Context, httpClient *http.Client, baseURL, email, password string) (int, error) {
	body, _ := json.Marshal(map[string]string{"identity": email, "password": password})

	var lastStatus int
	var lastErr error
	for _, ep := range []string{
		"/api/collections/_superusers/auth-with-password",
		"/api/admins/auth-with-password",
	} {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+ep, bytes.NewReader(body))
		if err != nil {
			return 0, err
		}
		req.Header.Set("Content-Type", "application/json")

		resp, err := httpClient.Do(req)
		if err != nil {
			return 0, err
		}
		raw, _ := io.ReadAll(resp.Body)
		resp.Body.Close()

		if resp.StatusCode == http.StatusOK {
			var out struct {
				Token string `json:"token"`
			}
			if err := json.Unmarshal(raw, &out); err != nil || out.Token == "" {
				return resp.StatusCode, fmt.Errorf("superuser auth: no token in response")
			}
			return resp.StatusCode, nil
		}
		lastStatus = resp.StatusCode
		lastErr = &APIError{Status: resp.StatusCode, Body: string(raw)}
	}
	return lastStatus, lastErr
}

// AuthRecord is the user record returned by a successful password auth.
type AuthRecord struct {
	ID    string `json:"id"`
	Email string `json:"email"`
	Name  string `json:"name"`
}

// AuthWithPassword verifies a user's credentials against an auth collection
// (e.g. "users"). This is an unauthenticated PocketBase call — it does not use
// the superuser token. Returns the matched user record on success.
func (c *Client) AuthWithPassword(ctx context.Context, collection, identity, password string) (*AuthRecord, error) {
	body, _ := json.Marshal(map[string]string{"identity": identity, "password": password})
	url := c.BaseURL() + "/api/collections/" + collection + "/auth-with-password"

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		return nil, &APIError{Status: resp.StatusCode, Body: string(raw)}
	}
	var out struct {
		Record AuthRecord `json:"record"`
	}
	if err := json.Unmarshal(raw, &out); err != nil {
		return nil, err
	}
	return &out.Record, nil
}

// ListResult is the envelope PocketBase returns from list endpoints.
type ListResult struct {
	Page       int             `json:"page"`
	PerPage    int             `json:"perPage"`
	TotalItems int             `json:"totalItems"`
	TotalPages int             `json:"totalPages"`
	Items      json.RawMessage `json:"items"`
}

// List fetches records from a collection. The query (filter, sort, perPage,
// expand, ...) is passed through as URL query parameters.
func (c *Client) List(ctx context.Context, collection string, query url.Values) (*ListResult, error) {
	path := "/api/collections/" + collection + "/records"
	if len(query) > 0 {
		path += "?" + query.Encode()
	}
	raw, err := c.do(ctx, http.MethodGet, path, nil)
	if err != nil {
		return nil, err
	}
	var lr ListResult
	if err := json.Unmarshal(raw, &lr); err != nil {
		return nil, err
	}
	return &lr, nil
}

// GetOne fetches a single record by id and unmarshals it into dest.
func (c *Client) GetOne(ctx context.Context, collection, id string, dest any) error {
	raw, err := c.do(ctx, http.MethodGet, "/api/collections/"+collection+"/records/"+id, nil)
	if err != nil {
		return err
	}
	return json.Unmarshal(raw, dest)
}

// Create inserts a record and unmarshals the created record into dest.
func (c *Client) Create(ctx context.Context, collection string, payload any, dest any) error {
	raw, err := c.do(ctx, http.MethodPost, "/api/collections/"+collection+"/records", payload)
	if err != nil {
		return err
	}
	if dest != nil {
		return json.Unmarshal(raw, dest)
	}
	return nil
}

// Update patches a record and unmarshals the updated record into dest.
func (c *Client) Update(ctx context.Context, collection, id string, payload any, dest any) error {
	raw, err := c.do(ctx, http.MethodPatch, "/api/collections/"+collection+"/records/"+id, payload)
	if err != nil {
		return err
	}
	if dest != nil {
		return json.Unmarshal(raw, dest)
	}
	return nil
}

// Delete removes a record by id.
func (c *Client) Delete(ctx context.Context, collection, id string) error {
	_, err := c.do(ctx, http.MethodDelete, "/api/collections/"+collection+"/records/"+id, nil)
	return err
}

// GetFile downloads a file field's stored content, authenticating as the
// superuser (this project's collections have no public access rules).
func (c *Client) GetFile(ctx context.Context, collection, recordID, filename string) ([]byte, string, error) {
	path := "/api/files/" + collection + "/" + recordID + "/" + filename
	if err := c.ensureToken(ctx); err != nil {
		return nil, "", err
	}
	raw, ctype, status, err := c.attemptGetFile(ctx, path)
	if err != nil {
		return nil, "", err
	}
	if status == http.StatusUnauthorized {
		if aerr := c.Authenticate(ctx); aerr != nil {
			return nil, "", aerr
		}
		raw, ctype, status, err = c.attemptGetFile(ctx, path)
		if err != nil {
			return nil, "", err
		}
	}
	if status < 200 || status >= 300 {
		return nil, "", &APIError{Status: status, Body: string(raw)}
	}
	return raw, ctype, nil
}

func (c *Client) attemptGetFile(ctx context.Context, path string) ([]byte, string, int, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL()+path, nil)
	if err != nil {
		return nil, "", 0, err
	}
	if tok := c.currentToken(); tok != "" {
		req.Header.Set("Authorization", tok)
	}
	resp, err := c.http.Do(req)
	if err != nil {
		return nil, "", 0, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, "", 0, err
	}
	return raw, resp.Header.Get("Content-Type"), resp.StatusCode, nil
}

// RequestVerification asks PocketBase to send a verification email for the
// given address (a public PocketBase endpoint; it always "succeeds" whether or
// not the email exists, to avoid leaking account existence — and it only
// actually sends mail if the PocketBase instance has SMTP configured).
func (c *Client) RequestVerification(ctx context.Context, collection, email string) error {
	_, err := c.do(ctx, http.MethodPost, "/api/collections/"+collection+"/request-verification", map[string]string{"email": email})
	return err
}

// UpdateMultipart patches a record via multipart/form-data — required for file
// fields (e.g. the users.avatar upload), which PocketBase doesn't accept as
// plain JSON. Pass fileField == "" to send only the plain fields.
func (c *Client) UpdateMultipart(ctx context.Context, collection, id string, fields map[string]string, fileField, filename string, fileContent []byte) error {
	if err := c.ensureToken(ctx); err != nil {
		return err
	}
	status, err := c.attemptMultipart(ctx, collection, id, fields, fileField, filename, fileContent)
	if status == http.StatusUnauthorized {
		if aerr := c.Authenticate(ctx); aerr != nil {
			return aerr
		}
		_, err = c.attemptMultipart(ctx, collection, id, fields, fileField, filename, fileContent)
	}
	return err
}

func (c *Client) attemptMultipart(ctx context.Context, collection, id string, fields map[string]string, fileField, filename string, fileContent []byte) (int, error) {
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	for k, v := range fields {
		if err := mw.WriteField(k, v); err != nil {
			return 0, err
		}
	}
	if fileField != "" {
		fw, err := mw.CreateFormFile(fileField, filename)
		if err != nil {
			return 0, err
		}
		if _, err := fw.Write(fileContent); err != nil {
			return 0, err
		}
	}
	if err := mw.Close(); err != nil {
		return 0, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.BaseURL()+"/api/collections/"+collection+"/records/"+id, &buf)
	if err != nil {
		return 0, err
	}
	req.Header.Set("Content-Type", mw.FormDataContentType())
	if tok := c.currentToken(); tok != "" {
		req.Header.Set("Authorization", tok)
	}

	resp, err := c.http.Do(req)
	if err != nil {
		return 0, err
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(resp.Body)
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return resp.StatusCode, &APIError{Status: resp.StatusCode, Body: string(raw)}
	}
	return resp.StatusCode, nil
}
