package internal

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

	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)

// Object types.
const (
	ConfigType = "Configuration"
	ViewType   = "View"
	ZoneType   = "Zone"
	TXTType    = "TXTRecord"
)

const authorizationHeader = "Authorization"

type Client struct {
	username string
	password string

	tokenExp *regexp.Regexp

	baseURL    *url.URL
	HTTPClient *http.Client
}

func NewClient(baseURL string, username, password string) *Client {
	bu, _ := url.Parse(baseURL)

	return &Client{
		username:   username,
		password:   password,
		tokenExp:   regexp.MustCompile("BAMAuthToken: [^ ]+"),
		baseURL:    bu,
		HTTPClient: &http.Client{Timeout: 30 * time.Second},
	}
}

// Deploy the DNS config for the specified entity to the authoritative servers.
// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/quickDeploy/9.5.0
func (c *Client) Deploy(ctx context.Context, entityID uint) error {
	endpoint := c.createEndpoint("quickDeploy")

	q := endpoint.Query()
	q.Set("entityId", strconv.FormatUint(uint64(entityID), 10))
	endpoint.RawQuery = q.Encode()

	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil)
	if err != nil {
		return err
	}

	resp, err := c.doAuthenticated(ctx, req)
	if err != nil {
		return errutils.NewHTTPDoError(req, err)
	}

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

	// The API doc says that 201 is expected but in the reality 200 is return.
	if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
	}

	return nil
}

// AddEntity A generic method for adding configurations, DNS zones, and DNS resource records.
// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/addEntity/9.5.0
func (c *Client) AddEntity(ctx context.Context, parentID uint, entity Entity) (uint64, error) {
	endpoint := c.createEndpoint("addEntity")

	q := endpoint.Query()
	q.Set("parentId", strconv.FormatUint(uint64(parentID), 10))
	endpoint.RawQuery = q.Encode()

	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, entity)
	if err != nil {
		return 0, err
	}

	resp, err := c.doAuthenticated(ctx, req)
	if err != nil {
		return 0, errutils.NewHTTPDoError(req, err)
	}

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

	if resp.StatusCode != http.StatusOK {
		return 0, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
	}

	raw, _ := io.ReadAll(resp.Body)

	// addEntity responds only with body text containing the ID of the created record
	addTxtResp := string(raw)
	id, err := strconv.ParseUint(addTxtResp, 10, 64)
	if err != nil {
		return 0, fmt.Errorf("addEntity request failed: %s", addTxtResp)
	}

	return id, nil
}

// GetEntityByName Returns objects from the database referenced by their database ID and with its properties fields populated.
// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/getEntityById/9.5.0
func (c *Client) GetEntityByName(ctx context.Context, parentID uint, name, objType string) (*EntityResponse, error) {
	endpoint := c.createEndpoint("getEntityByName")

	q := endpoint.Query()
	q.Set("parentId", strconv.FormatUint(uint64(parentID), 10))
	q.Set("name", name)
	q.Set("type", objType)
	endpoint.RawQuery = q.Encode()

	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}

	resp, err := c.doAuthenticated(ctx, req)
	if err != nil {
		return nil, errutils.NewHTTPDoError(req, err)
	}

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

	if resp.StatusCode != http.StatusOK {
		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
	}

	raw, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
	}

	var entity EntityResponse
	err = json.Unmarshal(raw, &entity)
	if err != nil {
		return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
	}

	return &entity, nil
}

// Delete Deletes an object using the generic delete method.
// https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/DELETE/v1/delete/9.5.0
func (c *Client) Delete(ctx context.Context, objectID uint) error {
	endpoint := c.createEndpoint("delete")

	q := endpoint.Query()
	q.Set("objectId", strconv.FormatUint(uint64(objectID), 10))
	endpoint.RawQuery = q.Encode()

	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
	if err != nil {
		return err
	}

	resp, err := c.doAuthenticated(ctx, req)
	if err != nil {
		return errutils.NewHTTPDoError(req, err)
	}

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

	// The API doc says that 204 is expected but in the reality 200 is returned.
	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
	}

	return nil
}

// LookupViewID Find the DNS view with the given name within.
func (c *Client) LookupViewID(ctx context.Context, configName, viewName string) (uint, error) {
	// Lookup the entity ID of the configuration named in our properties.
	conf, err := c.GetEntityByName(ctx, 0, configName, ConfigType)
	if err != nil {
		return 0, err
	}

	view, err := c.GetEntityByName(ctx, conf.ID, viewName, ViewType)
	if err != nil {
		return 0, err
	}

	return view.ID, nil
}

// LookupParentZoneID Return the entityId of the parent zone by recursing from the root view.
// Also return the simple name of the host.
func (c *Client) LookupParentZoneID(ctx context.Context, viewID uint, fqdn string) (uint, string, error) {
	if fqdn == "" {
		return viewID, "", nil
	}

	zones := strings.Split(strings.Trim(fqdn, "."), ".")

	name := zones[0]
	parentViewID := viewID

	for i := len(zones) - 1; i > -1; i-- {
		zone, err := c.GetEntityByName(ctx, parentViewID, zones[i], ZoneType)
		if err != nil {
			return 0, "", fmt.Errorf("could not find zone named %s: %w", name, err)
		}

		if zone == nil || zone.ID == 0 {
			break
		}

		if i > 0 {
			name = strings.Join(zones[0:i], ".")
		}

		parentViewID = zone.ID
	}

	return parentViewID, name, nil
}

func (c *Client) createEndpoint(resource string) *url.URL {
	return c.baseURL.JoinPath("Services", "REST", "v1", resource)
}

func (c *Client) doAuthenticated(ctx context.Context, req *http.Request) (*http.Response, error) {
	tok := getToken(ctx)
	if tok != "" {
		req.Header.Set(authorizationHeader, tok)
	}

	return c.HTTPClient.Do(req)
}

func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, 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, method, endpoint.String(), buf)
	if err != nil {
		return nil, fmt.Errorf("unable to create request: %w", err)
	}

	req.Header.Set("Accept", "application/json")

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

	return req, nil
}
