package clinic

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"
)

const defaultTimeout = 0 * time.Second

type Option func(*Client)

type Client struct {
	baseURL        *url.URL
	apiKey         string
	httpClient     *http.Client
	defaultTimeout time.Duration
}

type Request struct {
	Method  string
	Path    string
	RawPath string
	Query   url.Values
	Headers http.Header
	Body    any
	Timeout time.Duration
}

type HTTPError struct {
	StatusCode int
	Status     string
	Body       string
}

func (e *HTTPError) Error() string {
	var message string
	if e.Body == "" {
		message = fmt.Sprintf("clinic request failed: %s", e.Status)
	} else {
		message = fmt.Sprintf("clinic request failed: %s: %s", e.Status, e.Body)
	}

	if e.shouldSuggestAuthSetKey() {
		message += `. Run "clinic auth set-key" to make sure you are authenticated.`
	}

	return message
}

func (e *HTTPError) shouldSuggestAuthSetKey() bool {
	if e.StatusCode != http.StatusUnauthorized {
		return false
	}

	return strings.Contains(strings.ToLower(strings.TrimSpace(e.Body)), "invalid user")
}

func NewClient(baseURL, apiKey string, options ...Option) (*Client, error) {
	parsed, err := url.Parse(baseURL)
	if err != nil {
		return nil, fmt.Errorf("parse base url: %w", err)
	}
	if parsed == nil || parsed.Scheme == "" || parsed.Host == "" {
		return nil, fmt.Errorf("invalid base url: %q", baseURL)
	}

	client := &Client{
		baseURL:        parsed,
		apiKey:         apiKey,
		httpClient:     &http.Client{},
		defaultTimeout: defaultTimeout,
	}
	for _, option := range options {
		option(client)
	}

	return client, nil
}

func WithDefaultTimeout(timeout time.Duration) Option {
	return func(client *Client) {
		client.defaultTimeout = timeout
	}
}

func (c *Client) Do(ctx context.Context, req Request, out any) error {
	httpReq, cancel, err := c.buildRequest(ctx, req)
	if err != nil {
		return err
	}
	if cancel != nil {
		defer cancel()
	}

	resp, err := c.httpClient.Do(httpReq)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

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

	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		return &HTTPError{
			StatusCode: resp.StatusCode,
			Status:     resp.Status,
			Body:       string(body),
		}
	}

	if out == nil {
		return nil
	}

	if raw, ok := out.(*[]byte); ok {
		*raw = append((*raw)[:0], body...)
		return nil
	}

	if len(body) == 0 {
		return nil
	}

	if err := json.Unmarshal(body, out); err != nil {
		return fmt.Errorf("decode response body: %w", err)
	}

	return nil
}

func (c *Client) buildRequest(ctx context.Context, req Request) (*http.Request, context.CancelFunc, error) {
	if req.Method == "" {
		return nil, nil, fmt.Errorf("missing request method")
	}

	requestURL := *c.baseURL
	if req.RawPath != "" {
		decodedRequestRawPath, err := url.PathUnescape(req.RawPath)
		if err != nil {
			return nil, nil, fmt.Errorf("decode request RawPath: %w", err)
		}
		if decodedRequestRawPath != req.Path {
			return nil, nil, fmt.Errorf("request RawPath does not match Path: decoded RawPath %q, Path %q", decodedRequestRawPath, req.Path)
		}

		joinedRawPath := joinURLPath(c.baseURL.EscapedPath(), req.RawPath)
		joinedPath, err := url.PathUnescape(joinedRawPath)
		if err != nil {
			return nil, nil, fmt.Errorf("decode joined request RawPath: %w", err)
		}
		requestURL.Path = joinedPath
		requestURL.RawPath = joinedRawPath
	} else {
		requestURL.Path = joinURLPath(c.baseURL.Path, req.Path)
	}
	requestURL.RawQuery = ""
	if len(req.Query) > 0 {
		requestURL.RawQuery = req.Query.Encode()
	}

	bodyReader, err := marshalBody(req.Body)
	if err != nil {
		return nil, nil, err
	}

	timeout := c.defaultTimeout
	if req.Timeout > 0 {
		timeout = req.Timeout
	}

	var cancel context.CancelFunc
	if timeout > 0 {
		ctx, cancel = context.WithTimeout(ctx, timeout)
	}

	httpReq, err := http.NewRequestWithContext(ctx, req.Method, requestURL.String(), bodyReader)
	if err != nil {
		if cancel != nil {
			cancel()
		}
		return nil, nil, fmt.Errorf("build request: %w", err)
	}

	for key, values := range req.Headers {
		for _, value := range values {
			httpReq.Header.Add(key, value)
		}
	}
	if c.apiKey != "" {
		httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
	}
	if req.Body != nil && httpReq.Header.Get("Content-Type") == "" {
		httpReq.Header.Set("Content-Type", "application/json")
	}

	return httpReq, cancel, nil
}

func marshalBody(body any) (io.Reader, error) {
	if body == nil {
		return nil, nil
	}

	data, err := json.Marshal(body)
	if err != nil {
		return nil, fmt.Errorf("encode request body: %w", err)
	}

	return bytes.NewReader(data), nil
}

func joinURLPath(basePath, requestPath string) string {
	switch {
	case basePath == "" && requestPath == "":
		return ""
	case basePath == "":
		return ensureLeadingSlash(requestPath)
	case requestPath == "":
		return ensureLeadingSlash(basePath)
	default:
		return ensureLeadingSlash(strings.TrimRight(basePath, "/") + "/" + strings.TrimLeft(requestPath, "/"))
	}
}

func ensureLeadingSlash(path string) string {
	if path == "" || strings.HasPrefix(path, "/") {
		return path
	}
	return "/" + path
}
