package goacmedns

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

// defaultTimeout is used for the httpClient Timeout settings.
const defaultTimeout = 30 * time.Second

// ua is a custom user-agent identifier.
const ua = "goacmedns"

// userAgent returns a string that can be used as a HTTP request `User-Agent`
// header. It includes the `ua` string alongside the OS and architecture of the
// system.
func userAgent() string {
	return fmt.Sprintf("%s (%s; %s)", ua, runtime.GOOS, runtime.GOARCH)
}

type Register struct {
	AllowFrom []string `json:"allowfrom"`
}

type Update struct {
	SubDomain string `json:"subdomain"`
	Txt       string `json:"txt"`
}

// Storage is an interface describing the required functions for an ACME DNS
// Account storage mechanism.
type Storage interface {
	// Save will persist the `Account` data that has been `Put` so far
	Save(ctx context.Context) error
	// Put will add an `Account` for the given domain to the storage. It may not
	// be persisted until `Save` is called.
	Put(ctx context.Context, domain string, account Account) error
	// Fetch will retrieve an `Account` for the given domain from the storage. If
	// the provided domain does not have an `Account` saved in the storage
	// `ErrDomainNotFound` will be returned
	Fetch(ctx context.Context, domain string) (Account, error)
	// FetchAll retrieves all the `Account` objects from the storage and
	// returns a map that has domain names as its keys and `Account` objects
	// as values.
	FetchAll(ctx context.Context) (map[string]Account, error)
}

type Option func(c *Client)

func WithHTTPClient(client *http.Client) Option {
	return func(c *Client) {
		if c != nil {
			c.httpClient = client
		}
	}
}

type Client struct {
	httpClient *http.Client
	baseURL    *url.URL
}

func NewClient(baseURL string, opts ...Option) (*Client, error) {
	endpoint, err := url.Parse(baseURL)
	if err != nil {
		return nil, fmt.Errorf("could not parse base URL: %w", err)
	}

	client := &Client{
		httpClient: &http.Client{
			CheckRedirect: nil,
			Jar:           nil,
			Timeout:       defaultTimeout,
			Transport: &http.Transport{
				Proxy: http.ProxyFromEnvironment,
				DialContext: (&net.Dialer{
					Timeout:   defaultTimeout,
					KeepAlive: defaultTimeout,
				}).DialContext,
				TLSHandshakeTimeout:   defaultTimeout,
				ResponseHeaderTimeout: defaultTimeout,
				ExpectContinueTimeout: 1 * time.Second,
			},
		},
		baseURL: endpoint,
	}

	for _, opt := range opts {
		opt(client)
	}

	return client, nil
}

func (c *Client) RegisterAccount(ctx context.Context, allowFrom []string) (Account, error) {
	var register *Register
	if len(allowFrom) > 0 {
		register = &Register{AllowFrom: allowFrom}
	}

	req, err := newRequest(ctx, c.baseURL.JoinPath("register"), nil, register)
	if err != nil {
		return Account{}, err
	}

	var acct Account

	err = c.do(req, &acct)
	if err != nil {
		return Account{}, fmt.Errorf("failed to register account: %w", err)
	}

	acct.ServerURL = c.baseURL.String()

	return acct, nil
}

func (c *Client) UpdateTXTRecord(ctx context.Context, account Account, value string) error {
	update := &Update{
		SubDomain: account.SubDomain,
		Txt:       value,
	}

	headers := map[string]string{
		"X-Api-User": account.Username,
		"X-Api-Key":  account.Password,
	}

	req, err := newRequest(ctx, c.baseURL.JoinPath("update"), headers, update)
	if err != nil {
		return err
	}

	err = c.do(req, nil)
	if err != nil {
		return fmt.Errorf("failed to update TXT record: %w", err)
	}

	return nil
}

func (c *Client) do(req *http.Request, result any) error {
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("failed to do req: %w", err)
	}

	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode/100 != 2 {
		raw, _ := io.ReadAll(resp.Body)

		return newClientError("response error", resp.StatusCode, raw)
	}

	if result == nil {
		return nil
	}

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

	err = json.Unmarshal(raw, result)
	if err != nil {
		return newClientError("failed to unmarshal response", resp.StatusCode, raw)
	}

	return nil
}

func newRequest(ctx context.Context, endpoint *url.URL, headers map[string]string, payload any) (*http.Request, error) {
	buf := new(bytes.Buffer)

	if payload != nil {
		err := json.NewEncoder(buf).Encode(payload)
		if err != nil {
			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
		}
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), buf)
	if err != nil {
		return nil, fmt.Errorf("unable to create request: %w", err)
	}

	req.Header.Set("Accept", "application/json")
	req.Header.Set("User-Agent", userAgent())

	for h, v := range headers {
		req.Header.Set(h, v)
	}

	if payload != nil {
		req.Header.Set("Content-Type", "application/json")
	}

	return req, nil
}
