package identity

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/google/uuid"
)

var (
	ErrTenantContextNotFound    = errors.New("tenant context was not found")
	ErrTenantContextUnavailable = errors.New("tenant context is unavailable")
)

type TenantContext struct {
	ApplicationID           string    `json:"application_id"`
	TenantID                string    `json:"tenant_id"`
	DisplayName             string    `json:"display_name"`
	Slug                    string    `json:"slug"`
	TenantStatus            string    `json:"tenant_status"`
	TenantApplicationStatus string    `json:"tenant_application_status"`
	Version                 string    `json:"version"`
	UpdatedAt               time.Time `json:"updated_at"`
	ETag                    string    `json:"-"`
}

func (context TenantContext) Active() bool {
	return context.TenantStatus == "active" && context.TenantApplicationStatus == "active"
}

type TenantContextCredentialProvider func(context.Context) (string, []byte, error)

type TenantContextClient struct {
	issuer      string
	appEnv      string
	application string
	endpoint    string
	audience    string
	scope       string
	credentials TenantContextCredentialProvider
	client      *http.Client
	mutex       sync.Mutex
	token       string
	tokenExpiry time.Time
	tokenURL    string
}

func NewTenantContextClient(appEnv, issuer, applicationID, endpoint, audience, scope string, credentials TenantContextCredentialProvider, base *http.Client) (*TenantContextClient, error) {
	issuer = strings.TrimRight(strings.TrimSpace(issuer), "/")
	endpoint = strings.TrimSpace(endpoint)
	applicationID = strings.TrimSpace(applicationID)
	if validatePublicIdentityURL(issuer, appEnv) != nil ||
		validatePublicIdentityURL(strings.ReplaceAll(endpoint, "{tenantId}", uuid.Nil.String()), appEnv) != nil ||
		!strings.Contains(endpoint, "{tenantId}") || uuid.Validate(applicationID) != nil ||
		audience != "urn:easyai:auth-center:tenant-context" || scope != "tenant.context.read" || credentials == nil {
		return nil, errors.New("tenant context configuration is invalid")
	}
	if base == nil {
		base = &http.Client{Timeout: 10 * time.Second}
	}
	client := *base
	if client.Timeout <= 0 {
		client.Timeout = 10 * time.Second
	}
	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
	return &TenantContextClient{
		issuer: issuer, appEnv: appEnv, application: applicationID, endpoint: endpoint,
		audience: audience, scope: scope, credentials: credentials, client: &client,
	}, nil
}

func (client *TenantContextClient) Get(ctx context.Context, tenantID, etag string) (TenantContext, bool, error) {
	if uuid.Validate(strings.TrimSpace(tenantID)) != nil {
		return TenantContext{}, false, ErrTenantContextNotFound
	}
	token, err := client.accessToken(ctx)
	if err != nil {
		return TenantContext{}, false, ErrTenantContextUnavailable
	}
	request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.ReplaceAll(client.endpoint, "{tenantId}", tenantID), nil)
	if err != nil {
		return TenantContext{}, false, ErrTenantContextUnavailable
	}
	request.Header.Set("Accept", "application/json")
	request.Header.Set("Authorization", "Bearer "+token)
	if strings.TrimSpace(etag) != "" {
		request.Header.Set("If-None-Match", strings.TrimSpace(etag))
	}
	response, err := client.client.Do(request)
	if err != nil {
		return TenantContext{}, false, ErrTenantContextUnavailable
	}
	defer response.Body.Close()
	if response.StatusCode == http.StatusNotModified {
		_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
		return TenantContext{}, true, nil
	}
	if response.StatusCode == http.StatusNotFound {
		_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
		return TenantContext{}, false, ErrTenantContextNotFound
	}
	if response.StatusCode != http.StatusOK {
		_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
		return TenantContext{}, false, ErrTenantContextUnavailable
	}
	var output TenantContext
	decoder := json.NewDecoder(io.LimitReader(response.Body, maxOnboardingResponseBytes))
	if decoder.Decode(&output) != nil || output.ApplicationID != client.application || output.TenantID != tenantID ||
		strings.TrimSpace(output.DisplayName) == "" || strings.TrimSpace(output.Slug) == "" || strings.TrimSpace(output.Version) == "" ||
		output.UpdatedAt.IsZero() {
		return TenantContext{}, false, ErrTenantContextUnavailable
	}
	output.ETag = strings.TrimSpace(response.Header.Get("ETag"))
	return output, false, nil
}

func (client *TenantContextClient) accessToken(ctx context.Context) (string, error) {
	client.mutex.Lock()
	defer client.mutex.Unlock()
	if client.token != "" && time.Now().Add(30*time.Second).Before(client.tokenExpiry) {
		return client.token, nil
	}
	if client.tokenURL == "" {
		request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.issuer+"/.well-known/openid-configuration", nil)
		if err != nil {
			return "", err
		}
		request.Header.Set("Accept", "application/json")
		response, err := client.client.Do(request)
		if err != nil {
			return "", err
		}
		defer response.Body.Close()
		var discovery struct {
			Issuer        string `json:"issuer"`
			TokenEndpoint string `json:"token_endpoint"`
		}
		if response.StatusCode != http.StatusOK ||
			json.NewDecoder(io.LimitReader(response.Body, maxOnboardingResponseBytes)).Decode(&discovery) != nil ||
			strings.TrimRight(discovery.Issuer, "/") != client.issuer ||
			validatePublicIdentityURL(discovery.TokenEndpoint, client.appEnv) != nil {
			return "", errors.New("tenant context discovery is invalid")
		}
		client.tokenURL = discovery.TokenEndpoint
	}
	clientID, secret, err := client.credentials(ctx)
	if err != nil {
		return "", err
	}
	defer clear(secret)
	if strings.TrimSpace(clientID) == "" || len(secret) < 16 {
		return "", errors.New("tenant context credential is unavailable")
	}
	form := url.Values{"grant_type": {"client_credentials"}, "scope": {client.scope}}
	request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.tokenURL, strings.NewReader(form.Encode()))
	if err != nil {
		return "", err
	}
	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	request.Header.Set("Accept", "application/json")
	request.SetBasicAuth(clientID, string(secret))
	response, err := client.client.Do(request)
	if err != nil {
		return "", err
	}
	defer response.Body.Close()
	var tokenResponse struct {
		AccessToken string `json:"access_token"`
		TokenType   string `json:"token_type"`
		ExpiresIn   int    `json:"expires_in"`
	}
	if response.StatusCode != http.StatusOK ||
		json.NewDecoder(io.LimitReader(response.Body, maxOnboardingResponseBytes)).Decode(&tokenResponse) != nil ||
		tokenResponse.AccessToken == "" || !strings.EqualFold(tokenResponse.TokenType, "Bearer") ||
		tokenResponse.ExpiresIn <= 0 {
		return "", errors.New("tenant context token response is invalid")
	}
	client.token = tokenResponse.AccessToken
	client.tokenExpiry = time.Now().Add(time.Duration(tokenResponse.ExpiresIn) * time.Second)
	return client.token, nil
}
