// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
//
// SPDX-License-Identifier: AGPL-3.0-or-later

package lunatask

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

// API error types for typed error handling
var (
	// ErrBadRequest indicates invalid, malformed, or missing parameters (400)
	ErrBadRequest = errors.New("bad request")
	// ErrUnauthorized indicates missing, wrong, or revoked access token (401)
	ErrUnauthorized = errors.New("unauthorized")
	// ErrPaymentRequired indicates a subscription is required (402)
	ErrPaymentRequired = errors.New("subscription required")
	// ErrNotFound indicates the specified entity could not be found (404)
	ErrNotFound = errors.New("not found")
	// ErrUnprocessableEntity indicates the provided entity is not valid (422)
	ErrUnprocessableEntity = errors.New("unprocessable entity")
	// ErrServerError indicates an internal server error (500)
	ErrServerError = errors.New("server error")
	// ErrServiceUnavailable indicates temporary maintenance (503)
	ErrServiceUnavailable = errors.New("service unavailable")
	// ErrTimeout indicates request timed out (524)
	ErrTimeout = errors.New("request timed out")
)

// APIError wraps an API error with status code and response body
type APIError struct {
	StatusCode int
	Body       string
	Err        error
}

func (e *APIError) Error() string {
	if e.Body != "" {
		return fmt.Sprintf("%s (status %d): %s", e.Err.Error(), e.StatusCode, e.Body)
	}
	return fmt.Sprintf("%s (status %d)", e.Err.Error(), e.StatusCode)
}

func (e *APIError) Unwrap() error {
	return e.Err
}

// newAPIError creates an APIError from an HTTP status code
func newAPIError(statusCode int, body string) *APIError {
	var err error
	switch statusCode {
	case http.StatusBadRequest:
		err = ErrBadRequest
	case http.StatusUnauthorized:
		err = ErrUnauthorized
	case http.StatusPaymentRequired:
		err = ErrPaymentRequired
	case http.StatusNotFound:
		err = ErrNotFound
	case http.StatusUnprocessableEntity:
		err = ErrUnprocessableEntity
	case http.StatusInternalServerError:
		err = ErrServerError
	case http.StatusServiceUnavailable:
		err = ErrServiceUnavailable
	case 524:
		err = ErrTimeout
	default:
		err = fmt.Errorf("unexpected status %d", statusCode)
	}
	return &APIError{StatusCode: statusCode, Body: body, Err: err}
}

// DefaultBaseURL is the default Lunatask API base URL.
const DefaultBaseURL = "https://api.lunatask.app/v1"

// Client handles communication with the Lunatask API.
type Client struct {
	accessToken string
	baseURL     string
	httpClient  *http.Client
}

// Option configures a Client.
type Option func(*Client)

// WithHTTPClient sets a custom HTTP client.
func WithHTTPClient(client *http.Client) Option {
	return func(c *Client) {
		c.httpClient = client
	}
}

// WithBaseURL sets a custom base URL (useful for testing).
func WithBaseURL(url string) Option {
	return func(c *Client) {
		c.baseURL = url
	}
}

// NewClient creates a new Lunatask API client.
func NewClient(accessToken string, opts ...Option) *Client {
	c := &Client{
		accessToken: accessToken,
		baseURL:     DefaultBaseURL,
		httpClient:  &http.Client{},
	}
	for _, opt := range opts {
		opt(c)
	}
	return c
}

// doRequest performs an HTTP request and handles common response processing.
// Returns the response body and status code. 204 No Content returns nil body.
func (c *Client) doRequest(req *http.Request) ([]byte, int, error) {
	req.Header.Set("Authorization", "bearer "+c.accessToken)

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, 0, fmt.Errorf("failed to send HTTP request: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, resp.StatusCode, fmt.Errorf("failed to read response body: %w", err)
	}

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, resp.StatusCode, newAPIError(resp.StatusCode, string(body))
	}

	// 204 No Content is a valid success with no body
	if resp.StatusCode == http.StatusNoContent {
		return nil, resp.StatusCode, nil
	}

	return body, resp.StatusCode, nil
}

// doJSON performs an HTTP request with optional JSON body and unmarshals the response.
// Returns (nil, true, nil) for 204 No Content responses.
func doJSON[T any](c *Client, ctx context.Context, method, path string, body any) (*T, bool, error) {
	var reqBody io.Reader
	if body != nil {
		data, err := json.Marshal(body)
		if err != nil {
			return nil, false, fmt.Errorf("failed to marshal request body: %w", err)
		}
		reqBody = bytes.NewReader(data)
	}

	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
	if err != nil {
		return nil, false, fmt.Errorf("failed to create HTTP request: %w", err)
	}
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}

	respBody, statusCode, err := c.doRequest(req)
	if err != nil {
		return nil, false, err
	}

	if statusCode == http.StatusNoContent {
		return nil, true, nil
	}

	var result T
	if err := json.Unmarshal(respBody, &result); err != nil {
		return nil, false, fmt.Errorf("failed to parse response: %w", err)
	}

	return &result, false, nil
}

// PingResponse represents the response from the /ping endpoint.
type PingResponse struct {
	Message string `json:"message"`
}

// Ping verifies the access token is valid by calling the /ping endpoint.
// Returns the ping response on success, or an error (typically ErrUnauthorized) on failure.
func (c *Client) Ping(ctx context.Context) (*PingResponse, error) {
	resp, _, err := doJSON[PingResponse](c, ctx, http.MethodGet, "/ping", nil)
	return resp, err
}
