package npm

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

// Client is an HTTP client for the Nginx Proxy Manager API.
// It handles JWT authentication, automatic token refresh, and CRUD for proxy hosts.
type Client struct {
	baseURL    string
	httpClient *http.Client

	mu       sync.Mutex
	token    string
	expiry   time.Time
	email    string
	password string
}

// New creates an NPM client targeting the given base URL (e.g. "http://npm:81").
// Automatically appends "/api" if not already present.
func New(baseURL string) *Client {
	u := strings.TrimRight(baseURL, "/")
	if u != "" && !strings.HasSuffix(u, "/api") {
		u += "/api"
	}
	return &Client{
		baseURL: u,
		httpClient: &http.Client{
			Timeout: 30 * time.Second,
		},
	}
}

// Ping checks basic connectivity to the NPM API by issuing a lightweight GET request.
func (c *Client) Ping(ctx context.Context) error {
	if c.baseURL == "" {
		return fmt.Errorf("npm base URL not configured")
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/", nil)
	if err != nil {
		return fmt.Errorf("create ping request: %w", err)
	}
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("npm ping: %w", err)
	}
	resp.Body.Close()
	return nil
}

// Authenticate obtains a JWT from the NPM API and caches it for future requests.
// The credentials are also stored so the client can re-authenticate automatically on 401.
func (c *Client) Authenticate(ctx context.Context, email, password string) error {
	c.mu.Lock()
	c.email = email
	c.password = password
	c.mu.Unlock()

	return c.authenticate(ctx, email, password)
}

func (c *Client) authenticate(ctx context.Context, email, password string) error {
	body, err := json.Marshal(authRequest{
		Identity: email,
		Secret:   password,
	})
	if err != nil {
		return fmt.Errorf("marshal auth request: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/tokens", bytes.NewReader(body))
	if err != nil {
		return fmt.Errorf("create auth request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

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

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

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("authenticate: status %d: %s", resp.StatusCode, string(respBody))
	}

	var authResp authResponse
	if err := json.Unmarshal(respBody, &authResp); err != nil {
		return fmt.Errorf("decode auth response: %w", err)
	}

	expiry, err := time.Parse(time.RFC3339, authResp.Expires)
	if err != nil {
		// If we cannot parse the expiry, set a conservative 12-hour window.
		expiry = time.Now().Add(12 * time.Hour)
	}

	c.mu.Lock()
	c.token = authResp.Token
	c.expiry = expiry
	c.mu.Unlock()

	return nil
}

// CreateProxyHost creates a new proxy host and returns the created resource.
func (c *Client) CreateProxyHost(ctx context.Context, config ProxyHostConfig) (ProxyHost, error) {
	var host ProxyHost
	if err := c.doJSON(ctx, http.MethodPost, "/nginx/proxy-hosts", config, &host); err != nil {
		return ProxyHost{}, fmt.Errorf("create proxy host: %w", err)
	}
	return host, nil
}

// UpdateProxyHost updates an existing proxy host by ID and returns the updated resource.
func (c *Client) UpdateProxyHost(ctx context.Context, id int, config ProxyHostConfig) (ProxyHost, error) {
	var host ProxyHost
	path := fmt.Sprintf("/nginx/proxy-hosts/%d", id)
	if err := c.doJSON(ctx, http.MethodPut, path, config, &host); err != nil {
		return ProxyHost{}, fmt.Errorf("update proxy host %d: %w", id, err)
	}
	return host, nil
}

// DeleteProxyHost deletes a proxy host by ID.
func (c *Client) DeleteProxyHost(ctx context.Context, id int) error {
	path := fmt.Sprintf("/nginx/proxy-hosts/%d", id)
	if err := c.doJSON(ctx, http.MethodDelete, path, nil, nil); err != nil {
		return fmt.Errorf("delete proxy host %d: %w", id, err)
	}
	return nil
}

// ListProxyHosts returns all proxy hosts.
func (c *Client) ListProxyHosts(ctx context.Context) ([]ProxyHost, error) {
	var hosts []ProxyHost
	if err := c.doJSON(ctx, http.MethodGet, "/nginx/proxy-hosts", nil, &hosts); err != nil {
		return nil, fmt.Errorf("list proxy hosts: %w", err)
	}
	return hosts, nil
}

// FindProxyHostByDomain searches existing proxy hosts for one that serves the given domain.
// Returns the matching host and true if found, or a zero-value ProxyHost and false otherwise.
func (c *Client) FindProxyHostByDomain(ctx context.Context, domain string) (ProxyHost, bool, error) {
	hosts, err := c.ListProxyHosts(ctx)
	if err != nil {
		return ProxyHost{}, false, fmt.Errorf("find proxy host by domain: %w", err)
	}

	needle := strings.ToLower(domain)
	for _, h := range hosts {
		for _, d := range h.DomainNames {
			if strings.ToLower(d) == needle {
				return h, true, nil
			}
		}
	}

	return ProxyHost{}, false, nil
}

// ListCertificates returns all SSL certificates from NPM.
func (c *Client) ListCertificates(ctx context.Context) ([]Certificate, error) {
	var certs []Certificate
	if err := c.doJSON(ctx, http.MethodGet, "/nginx/certificates", nil, &certs); err != nil {
		return nil, fmt.Errorf("list certificates: %w", err)
	}
	return certs, nil
}

// doJSON performs an authenticated JSON API request. If the token is expired or a 401
// is received, it automatically re-authenticates and retries the request once.
func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, result any) error {
	if err := c.ensureToken(ctx); err != nil {
		return err
	}

	err := c.doJSONOnce(ctx, method, path, reqBody, result)
	if err == nil {
		return nil
	}

	// If we got a 401, attempt re-auth and retry once.
	if isUnauthorized(err) {
		c.mu.Lock()
		email := c.email
		password := c.password
		c.mu.Unlock()

		if authErr := c.authenticate(ctx, email, password); authErr != nil {
			return fmt.Errorf("re-authenticate after 401: %w", authErr)
		}
		return c.doJSONOnce(ctx, method, path, reqBody, result)
	}

	return err
}

// errUnauthorized is a sentinel used to detect 401 responses for automatic re-auth.
type errUnauthorized struct {
	wrapped error
}

func (e *errUnauthorized) Error() string { return e.wrapped.Error() }
func (e *errUnauthorized) Unwrap() error { return e.wrapped }

func isUnauthorized(err error) bool {
	var target *errUnauthorized
	return errors.As(err, &target)
}

func (c *Client) doJSONOnce(ctx context.Context, method, path string, reqBody any, result any) error {
	var bodyReader io.Reader
	if reqBody != nil {
		data, err := json.Marshal(reqBody)
		if err != nil {
			return fmt.Errorf("marshal request body: %w", err)
		}
		bodyReader = bytes.NewReader(data)
	}

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

	c.mu.Lock()
	token := c.token
	c.mu.Unlock()
	req.Header.Set("Authorization", "Bearer "+token)

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("send request %s %s: %w", method, path, err)
	}
	defer resp.Body.Close()

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

	if resp.StatusCode == http.StatusUnauthorized {
		return &errUnauthorized{
			wrapped: fmt.Errorf("status 401: %s", string(respBody)),
		}
	}

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return fmt.Errorf("npm api %s %s: status %d: %s", method, path, resp.StatusCode, string(respBody))
	}

	// DELETE returns 200 with no body.
	if result != nil && len(respBody) > 0 {
		if err := json.Unmarshal(respBody, result); err != nil {
			return fmt.Errorf("decode response: %w", err)
		}
	}

	return nil
}

// ensureToken checks if the cached token is still valid and re-authenticates if needed.
func (c *Client) ensureToken(ctx context.Context) error {
	c.mu.Lock()
	token := c.token
	expiry := c.expiry
	email := c.email
	password := c.password
	c.mu.Unlock()

	if token == "" {
		return fmt.Errorf("npm client not authenticated: call Authenticate first")
	}

	// Refresh the token 5 minutes before expiry to avoid race conditions.
	if time.Now().Add(5 * time.Minute).After(expiry) {
		if err := c.authenticate(ctx, email, password); err != nil {
			return fmt.Errorf("refresh expired token: %w", err)
		}
	}

	return nil
}

// UnmarshalJSON allows boolInt to decode both JSON booleans and 0/1 integers.
func (b *boolInt) UnmarshalJSON(data []byte) error {
	s := strings.TrimSpace(string(data))
	switch s {
	case "true", "1":
		*b = true
	case "false", "0", "null":
		*b = false
	default:
		return fmt.Errorf("cannot unmarshal %q as boolInt", s)
	}
	return nil
}

// MarshalJSON encodes boolInt as a JSON boolean.
func (b boolInt) MarshalJSON() ([]byte, error) {
	if b {
		return []byte("true"), nil
	}
	return []byte("false"), nil
}
