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

// Package lunatask provides a Go client for the Lunatask API.
//
//	client := lunatask.NewClient(os.Getenv("LUNATASK_TOKEN"), lunatask.UserAgent("MyApp/1.0"))
//	if _, err := client.Ping(ctx); err != nil {
//		log.Fatal(err)
//	}
package lunatask

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

// 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")
	// ErrUnexpectedStatus indicates an unexpected HTTP status code.
	ErrUnexpectedStatus = errors.New("unexpected status")
)

// APIError wraps an API error with HTTP status and response body.
// Use [errors.Is] with the sentinel errors (e.g., [ErrNotFound]) for
// control flow, or type-assert to access StatusCode and 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 statusCloudflareTimeout:
		err = ErrTimeout
	default:
		err = ErrUnexpectedStatus
	}

	return &APIError{StatusCode: statusCode, Body: body, Err: err}
}

const (
	// DefaultBaseURL is the default Lunatask API base URL.
	DefaultBaseURL = "https://api.lunatask.app/v1"
	// statusCloudflareTimeout is Cloudflare's "A Timeout Occurred" status.
	statusCloudflareTimeout = 524
	// modulePath is the import path used to find this module's version.
	modulePath = "git.secluded.site/go-lunatask"
)

var (
	versionOnce sync.Once //nolint:gochecknoglobals
	version     = "unknown"
)

// getVersion returns the library version, determined from build info.
func getVersion() string {
	versionOnce.Do(func() {
		buildInfo, ok := debug.ReadBuildInfo()
		if !ok {
			return
		}

		// When used as a dependency, find our version in the deps list.
		for _, dep := range buildInfo.Deps {
			if dep.Path == modulePath {
				version = dep.Version

				return
			}
		}

		// When running as main module (e.g., tests), use main module version.
		if buildInfo.Main.Path == modulePath && buildInfo.Main.Version != "" && buildInfo.Main.Version != "(devel)" {
			version = buildInfo.Main.Version
		}
	})

	return version
}

// Client handles communication with the Lunatask API.
// A Client is safe for concurrent use.
type Client struct {
	accessToken string
	baseURL     string
	httpClient  *http.Client
	userAgent   string
}

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

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

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

// UserAgent sets a custom User-Agent prefix.
// The library identifier (go-lunatask/version) is always appended.
func UserAgent(ua string) Option {
	return func(c *Client) {
		c.userAgent = ua
	}
}

// NewClient creates a new Lunatask API client.
// Generate an access token in the Lunatask desktop app under Settings → Access tokens.
func NewClient(accessToken string, opts ...Option) *Client {
	client := &Client{
		accessToken: accessToken,
		baseURL:     DefaultBaseURL,
		httpClient:  &http.Client{},
		userAgent:   "",
	}
	for _, opt := range opts {
		opt(client)
	}

	return client
}

// 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](ctx, c, http.MethodGet, "/ping", nil)

	return resp, err
}

// 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)

	ua := "go-lunatask/" + getVersion()
	if c.userAgent != "" {
		ua = c.userAgent + " " + ua
	}

	req.Header.Set("User-Agent", ua)

	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](ctx context.Context, client *Client, 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, client.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 := client.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
}
