package dcos

import (
	"bytes"
	"context"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

const (
	// How long to stayed logged in for
	loginDuration = 65 * time.Minute
)

// client is an interface for communicating with the DC/OS API.
type client interface {
	setToken(token string)

	login(ctx context.Context, sa *serviceAccount) (*authToken, error)
	getSummary(ctx context.Context) (*summary, error)
	getContainers(ctx context.Context, node string) ([]container, error)
	getNodeMetrics(ctx context.Context, node string) (*metrics, error)
	getContainerMetrics(ctx context.Context, node, container string) (*metrics, error)
	getAppMetrics(ctx context.Context, node, container string) (*metrics, error)
}

type apiError struct {
	url         string
	statusCode  int
	title       string
	description string
}

// login is request data for logging in.
type login struct {
	UID   string `json:"uid"`
	Exp   int64  `json:"exp"`
	Token string `json:"token"`
}

// loginError is the response when login fails.
type loginError struct {
	Title       string `json:"title"`
	Description string `json:"description"`
}

// loginAuth is the response to a successful login.
type loginAuth struct {
	Token string `json:"token"`
}

// slave is a node in the cluster.
type slave struct {
	ID string `json:"id"`
}

// summary provides high level cluster wide information.
type summary struct {
	Cluster string
	Slaves  []slave
}

// container is a container on a node.
type container struct {
	ID string
}

type dataPoint struct {
	Name  string            `json:"name"`
	Tags  map[string]string `json:"tags"`
	Unit  string            `json:"unit"`
	Value float64           `json:"value"`
}

// metrics are the DCOS metrics
type metrics struct {
	Datapoints []dataPoint            `json:"datapoints"`
	Dimensions map[string]interface{} `json:"dimensions"`
}

// authToken is the authentication token.
type authToken struct {
	Text   string
	Expire time.Time
}

// clusterClient is a client that uses the cluster URL.
type clusterClient struct {
	clusterURL *url.URL
	httpClient *http.Client
	token      string
	semaphore  chan struct{}
}

type claims struct {
	UID string `json:"uid"`
	jwt.RegisteredClaims
}

func (e apiError) Error() string {
	if e.description != "" {
		return fmt.Sprintf("[%s] %s: %s", e.url, e.title, e.description)
	}
	return fmt.Sprintf("[%s] %s", e.url, e.title)
}

func newClusterClient(clusterURL *url.URL, timeout time.Duration, maxConns int, tlsConfig *tls.Config) *clusterClient {
	httpClient := &http.Client{
		Transport: &http.Transport{
			MaxIdleConns:    maxConns,
			TLSClientConfig: tlsConfig,
		},
		Timeout: timeout,
	}
	semaphore := make(chan struct{}, maxConns)

	c := &clusterClient{
		clusterURL: clusterURL,
		httpClient: httpClient,
		semaphore:  semaphore,
	}
	return c
}

func (c *clusterClient) setToken(token string) {
	c.token = token
}

func (c *clusterClient) login(ctx context.Context, sa *serviceAccount) (*authToken, error) {
	token, err := c.createLoginToken(sa)
	if err != nil {
		return nil, err
	}

	exp := time.Now().Add(loginDuration)

	body := &login{
		UID:   sa.accountID,
		Exp:   exp.Unix(),
		Token: token,
	}

	octets, err := json.Marshal(body)
	if err != nil {
		return nil, err
	}

	loc := c.toURL("/acs/api/v1/auth/login")
	req, err := http.NewRequest("POST", loc, bytes.NewBuffer(octets))
	if err != nil {
		return nil, err
	}
	req.Header.Add("Content-Type", "application/json")

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

	if resp.StatusCode == http.StatusOK {
		auth := &loginAuth{}
		dec := json.NewDecoder(resp.Body)
		err = dec.Decode(auth)
		if err != nil {
			return nil, err
		}

		token := &authToken{
			Text:   auth.Token,
			Expire: exp,
		}
		return token, nil
	}

	loginError := &loginError{}
	dec := json.NewDecoder(resp.Body)
	err = dec.Decode(loginError)
	if err != nil {
		err := &apiError{
			url:        loc,
			statusCode: resp.StatusCode,
			title:      resp.Status,
		}
		return nil, err
	}

	err = &apiError{
		url:         loc,
		statusCode:  resp.StatusCode,
		title:       loginError.Title,
		description: loginError.Description,
	}
	return nil, err
}

func (c *clusterClient) getSummary(ctx context.Context) (*summary, error) {
	summary := &summary{}
	err := c.doGet(ctx, c.toURL("/mesos/master/state-summary"), summary)
	if err != nil {
		return nil, err
	}

	return summary, nil
}

func (c *clusterClient) getContainers(ctx context.Context, node string) ([]container, error) {
	list := make([]string, 0)
	err := c.doGet(ctx, c.toURL(fmt.Sprintf("/system/v1/agent/%s/metrics/v0/containers", node)), &list)
	if err != nil {
		return nil, err
	}

	containers := make([]container, 0, len(list))
	for _, c := range list {
		containers = append(containers, container{ID: c})
	}

	return containers, nil
}

func (c *clusterClient) getMetrics(ctx context.Context, address string) (*metrics, error) {
	metrics := &metrics{}

	err := c.doGet(ctx, address, metrics)
	if err != nil {
		return nil, err
	}

	return metrics, nil
}

func (c *clusterClient) getNodeMetrics(ctx context.Context, node string) (*metrics, error) {
	path := fmt.Sprintf("/system/v1/agent/%s/metrics/v0/node", node)
	return c.getMetrics(ctx, c.toURL(path))
}

func (c *clusterClient) getContainerMetrics(ctx context.Context, node, container string) (*metrics, error) {
	path := fmt.Sprintf("/system/v1/agent/%s/metrics/v0/containers/%s", node, container)
	return c.getMetrics(ctx, c.toURL(path))
}

func (c *clusterClient) getAppMetrics(ctx context.Context, node, container string) (*metrics, error) {
	path := fmt.Sprintf("/system/v1/agent/%s/metrics/v0/containers/%s/app", node, container)
	return c.getMetrics(ctx, c.toURL(path))
}

func createGetRequest(address, token string) (*http.Request, error) {
	req, err := http.NewRequest("GET", address, nil)
	if err != nil {
		return nil, err
	}

	if token != "" {
		req.Header.Add("Authorization", "token="+token)
	}
	req.Header.Add("Accept", "application/json")

	return req, nil
}

func (c *clusterClient) doGet(ctx context.Context, address string, v interface{}) error {
	req, err := createGetRequest(address, c.token)
	if err != nil {
		return err
	}

	select {
	case c.semaphore <- struct{}{}:
		break
	case <-ctx.Done():
		return ctx.Err()
	}

	resp, err := c.httpClient.Do(req.WithContext(ctx))
	if err != nil {
		<-c.semaphore
		return err
	}
	defer func() {
		resp.Body.Close()
		<-c.semaphore
	}()

	// Clear invalid token if unauthorized
	if resp.StatusCode == http.StatusUnauthorized {
		c.token = ""
	}

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return &apiError{
			url:        address,
			statusCode: resp.StatusCode,
			title:      resp.Status,
		}
	}

	if resp.StatusCode == http.StatusNoContent {
		return nil
	}

	err = json.NewDecoder(resp.Body).Decode(v)
	return err
}

func (c *clusterClient) toURL(path string) string {
	clusterURL := *c.clusterURL
	clusterURL.Path = path
	return clusterURL.String()
}

func (c *clusterClient) createLoginToken(sa *serviceAccount) (string, error) {
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims{
		UID: sa.accountID,
		RegisteredClaims: jwt.RegisteredClaims{
			// How long we have to login with this token
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute * 5)),
		},
	})
	return token.SignedString(sa.privateKey)
}
