package identity

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

	"github.com/google/uuid"
)

const maxOnboardingResponseBytes = 1 << 20

type ExchangeStatus string

const (
	ExchangeMetadataPending     ExchangeStatus = "metadata_pending"
	ExchangePreparing           ExchangeStatus = "preparing"
	ExchangeReady               ExchangeStatus = "ready"
	ExchangeCredentialDelivered ExchangeStatus = "credential_delivered"
	ExchangeCompleted           ExchangeStatus = "completed"
	ExchangeFailed              ExchangeStatus = "failed"
	ExchangeExpired             ExchangeStatus = "expired"
)

type ClaimedExchange struct {
	ExchangeID    string    `json:"exchange_id"`
	ExchangeToken string    `json:"exchange_token"`
	ExpiresAt     time.Time `json:"expires_at"`
	Version       int64     `json:"version"`
}

type Exchange struct {
	ExchangeID        string         `json:"exchange_id"`
	ApplicationID     string         `json:"application_id"`
	Status            ExchangeStatus `json:"status"`
	Version           int64          `json:"version"`
	ExpiresAt         time.Time      `json:"expires_at"`
	LastErrorCategory string         `json:"last_error_category,omitempty"`
	AuditID           string         `json:"audit_id,omitempty"`
}

type ManifestClient struct {
	ClientID string `json:"client_id"`
}

type ManifestClients struct {
	BrowserLogin     *ManifestClient `json:"browser_login,omitempty"`
	MachineToMachine *ManifestClient `json:"machine_to_machine,omitempty"`
}

type ManifestSecurityEvents struct {
	TransmitterIssuer     string `json:"transmitter_issuer"`
	ConfigurationEndpoint string `json:"configuration_endpoint"`
	Audience              string `json:"audience"`
}

type ManifestTenantContext struct {
	Endpoint string `json:"endpoint"`
	Audience string `json:"audience"`
	Scope    string `json:"scope"`
}

type ManifestV1 struct {
	SchemaVersion  int                     `json:"schema_version"`
	TenantMode     string                  `json:"tenant_mode,omitempty"`
	Issuer         string                  `json:"issuer"`
	TenantID       string                  `json:"tenant_id,omitempty"`
	ApplicationID  string                  `json:"application_id"`
	Capabilities   []string                `json:"capabilities"`
	Audience       string                  `json:"audience,omitempty"`
	Scopes         []string                `json:"scopes"`
	Clients        ManifestClients         `json:"clients"`
	TenantContext  *ManifestTenantContext  `json:"tenant_context,omitempty"`
	SecurityEvents *ManifestSecurityEvents `json:"security_events,omitempty"`
}

// ManifestV2 intentionally shares the wire structure with ManifestV1 so the
// onboarding delivery can be decoded without guessing a union variant. Validate
// always branches on schema_version and rejects fields from the other mode.
type ManifestV2 = ManifestV1

type MachineCredential struct {
	ClientID     string    `json:"client_id"`
	ClientSecret string    `json:"client_secret"`
	IssuedAt     time.Time `json:"issued_at"`
}

type CredentialDelivery struct {
	Manifest          ManifestV1         `json:"manifest"`
	MachineCredential *MachineCredential `json:"machine_credential,omitempty"`
	Version           int64              `json:"-"`
}

func (manifest ManifestV1) Validate(appEnv string) error {
	if validatePublicIdentityURL(manifest.Issuer, appEnv) != nil {
		return errors.New("application manifest identity metadata is invalid")
	}
	switch manifest.SchemaVersion {
	case 1:
		if strings.TrimSpace(manifest.TenantMode) != "" || manifest.TenantContext != nil {
			return errors.New("application manifest tenant mode is invalid")
		}
		if _, err := uuid.Parse(manifest.TenantID); err != nil {
			return errors.New("application manifest tenant is invalid")
		}
	case 2:
		if manifest.TenantMode != "multi_tenant" || strings.TrimSpace(manifest.TenantID) != "" {
			return errors.New("application manifest tenant mode is invalid")
		}
		if manifest.TenantContext == nil ||
			validatePublicIdentityURL(strings.ReplaceAll(manifest.TenantContext.Endpoint, "{tenantId}", uuid.Nil.String()), appEnv) != nil ||
			!strings.Contains(manifest.TenantContext.Endpoint, "{tenantId}") ||
			manifest.TenantContext.Audience != "urn:easyai:auth-center:tenant-context" ||
			manifest.TenantContext.Scope != "tenant.context.read" {
			return errors.New("application manifest tenant context is invalid")
		}
	default:
		return errors.New("application manifest identity metadata is invalid")
	}
	if _, err := uuid.Parse(manifest.ApplicationID); err != nil {
		return errors.New("application manifest application is invalid")
	}
	allowed := map[string]bool{"oidc_login": true, "api_access": true, "machine_to_machine": true, "token_introspection": true, "session_revocation": true}
	capabilities := make(map[string]bool, len(manifest.Capabilities))
	for _, capability := range manifest.Capabilities {
		if !allowed[capability] || capabilities[capability] {
			return errors.New("application manifest capabilities are invalid")
		}
		capabilities[capability] = true
	}
	if capabilities["session_revocation"] && !capabilities["token_introspection"] ||
		capabilities["token_introspection"] && !capabilities["machine_to_machine"] {
		return errors.New("application manifest capability dependencies are invalid")
	}
	if manifest.SchemaVersion == 2 &&
		(!capabilities["oidc_login"] || !capabilities["machine_to_machine"] ||
			!capabilities["token_introspection"] || !capabilities["session_revocation"]) {
		return errors.New("application manifest multi-tenant capabilities are invalid")
	}
	if capabilities["oidc_login"] && (manifest.Clients.BrowserLogin == nil || strings.TrimSpace(manifest.Clients.BrowserLogin.ClientID) == "") {
		return errors.New("application manifest browser client is missing")
	}
	if capabilities["machine_to_machine"] && (manifest.Clients.MachineToMachine == nil || strings.TrimSpace(manifest.Clients.MachineToMachine.ClientID) == "") {
		return errors.New("application manifest machine client is missing")
	}
	if capabilities["api_access"] && strings.TrimSpace(manifest.Audience) == "" {
		return errors.New("application manifest audience is missing")
	}
	if capabilities["session_revocation"] && manifest.SecurityEvents == nil {
		return errors.New("application manifest security event metadata is invalid")
	}
	if manifest.SecurityEvents != nil {
		if validatePublicIdentityURL(manifest.SecurityEvents.TransmitterIssuer, appEnv) != nil ||
			validatePublicIdentityURL(manifest.SecurityEvents.ConfigurationEndpoint, appEnv) != nil || strings.TrimSpace(manifest.SecurityEvents.Audience) == "" {
			return errors.New("application manifest security event metadata is invalid")
		}
	}
	seenScopes := make(map[string]bool, len(manifest.Scopes))
	for _, scope := range manifest.Scopes {
		scope = strings.TrimSpace(scope)
		if scope == "" || seenScopes[scope] {
			return errors.New("application manifest scopes are invalid")
		}
		seenScopes[scope] = true
	}
	return nil
}

func validatePublicIdentityURL(raw, appEnv string) error {
	_, err := exactBaseURL(raw, appEnv)
	if err == nil {
		return nil
	}
	// Discovery endpoints may contain a path, but must retain the same URL
	// safety constraints as a base URL.
	parsed, parseErr := http.NewRequest(http.MethodGet, strings.TrimSpace(raw), nil)
	if parseErr != nil || parsed.URL.User != nil || parsed.URL.Host == "" || parsed.URL.RawQuery != "" || parsed.URL.Fragment != "" {
		return errors.New("identity URL is invalid")
	}
	scheme := strings.ToLower(parsed.URL.Scheme)
	if scheme == "https" || scheme == "http" && isLocalIdentityEnvironment(appEnv) && isLoopbackHost(strings.ToLower(parsed.URL.Hostname())) {
		return nil
	}
	return errors.New("identity URL must use HTTPS")
}

type OnboardingClient struct {
	baseURL string
	client  *http.Client
	appEnv  string
}

func NewOnboardingClient(baseURL string, base *http.Client, appEnv string) (*OnboardingClient, error) {
	normalized, err := exactBaseURL(baseURL, appEnv)
	if err != nil {
		return nil, errors.New("auth center URL 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 &OnboardingClient{baseURL: normalized, client: &client, appEnv: appEnv}, nil
}

func (client *OnboardingClient) Claim(ctx context.Context, code string) (ClaimedExchange, error) {
	var output ClaimedExchange
	status, _, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges", "", "", 0,
		map[string]string{"onboarding_code": strings.TrimSpace(code)}, &output)
	if err != nil || status != http.StatusCreated || output.Version < 1 || output.ExchangeID == "" || output.ExchangeToken == "" {
		return ClaimedExchange{}, onboardingProtocolError(err)
	}
	return output, nil
}

func (client *OnboardingClient) SubmitMetadata(ctx context.Context, claimed ClaimedExchange, metadata ConsumerMetadata, idempotencyKey string) (Exchange, error) {
	var output Exchange
	status, _, err := client.request(ctx, http.MethodPut, "/api/v1/onboarding-exchanges/"+claimed.ExchangeID+"/metadata",
		claimed.ExchangeToken, idempotencyKey, claimed.Version, metadata, &output)
	if err != nil || status != http.StatusAccepted || output.Version < 1 {
		return Exchange{}, onboardingProtocolError(err)
	}
	return output, nil
}

func (client *OnboardingClient) Get(ctx context.Context, exchangeID, exchangeToken string) (Exchange, error) {
	var output Exchange
	status, _, err := client.request(ctx, http.MethodGet, "/api/v1/onboarding-exchanges/"+exchangeID, exchangeToken, "", 0, nil, &output)
	if err != nil || status != http.StatusOK || output.Version < 1 {
		return Exchange{}, onboardingProtocolError(err)
	}
	return output, nil
}

func (client *OnboardingClient) DeliverCredential(ctx context.Context, exchange Exchange, exchangeToken, idempotencyKey string) (CredentialDelivery, error) {
	var output CredentialDelivery
	status, etag, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges/"+exchange.ExchangeID+"/credential-deliveries",
		exchangeToken, idempotencyKey, exchange.Version, nil, &output)
	if err != nil || status != http.StatusOK {
		return CredentialDelivery{}, onboardingProtocolError(err)
	}
	if err := output.Manifest.Validate(client.appEnv); err != nil {
		return CredentialDelivery{}, err
	}
	output.Version, err = parseWeakETag(etag)
	if err != nil {
		return CredentialDelivery{}, errors.New("onboarding response ETag is invalid")
	}
	return output, nil
}

func (client *OnboardingClient) Complete(ctx context.Context, exchangeID, exchangeToken, idempotencyKey string, version int64) error {
	status, _, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges/"+exchangeID+"/completion",
		exchangeToken, idempotencyKey, version, nil, nil)
	if err != nil || status != http.StatusNoContent {
		return onboardingProtocolError(err)
	}
	return nil
}

func (client *OnboardingClient) request(ctx context.Context, method, path, token, idempotencyKey string, version int64, body, output any) (int, string, error) {
	var reader io.Reader
	if body != nil {
		payload, err := json.Marshal(body)
		if err != nil {
			return 0, "", err
		}
		reader = bytes.NewReader(payload)
	}
	request, err := http.NewRequestWithContext(ctx, method, client.baseURL+path, reader)
	if err != nil {
		return 0, "", err
	}
	request.Header.Set("Accept", "application/json")
	if body != nil {
		request.Header.Set("Content-Type", "application/json")
	}
	if token != "" {
		request.Header.Set("Authorization", "Bearer "+token)
	}
	if idempotencyKey != "" {
		request.Header.Set("Idempotency-Key", idempotencyKey)
	}
	if version > 0 {
		request.Header.Set("If-Match", fmt.Sprintf(`W/"%d"`, version))
	}
	response, err := client.client.Do(request)
	if err != nil {
		return 0, "", errors.New("onboarding endpoint request failed")
	}
	defer response.Body.Close()
	if response.StatusCode >= 300 && response.StatusCode < 400 {
		return response.StatusCode, "", errors.New("onboarding endpoint redirect is forbidden")
	}
	if output == nil || response.StatusCode == http.StatusNoContent {
		_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes))
		return response.StatusCode, response.Header.Get("ETag"), nil
	}
	payload, err := io.ReadAll(io.LimitReader(response.Body, maxOnboardingResponseBytes+1))
	if err != nil || len(payload) > maxOnboardingResponseBytes {
		return response.StatusCode, "", errors.New("onboarding endpoint response is invalid")
	}
	if response.StatusCode < 200 || response.StatusCode >= 300 {
		return response.StatusCode, "", errors.New("onboarding endpoint rejected the request")
	}
	decoder := json.NewDecoder(bytes.NewReader(payload))
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(output); err != nil {
		return response.StatusCode, "", errors.New("onboarding endpoint response is invalid")
	}
	return response.StatusCode, response.Header.Get("ETag"), nil
}

func onboardingProtocolError(cause error) error {
	if cause == nil {
		return errors.New("onboarding endpoint returned an unexpected status")
	}
	return cause
}

func parseWeakETag(value string) (int64, error) {
	value = strings.TrimSpace(value)
	if !strings.HasPrefix(value, `W/"`) || !strings.HasSuffix(value, `"`) {
		return 0, errors.New("invalid ETag")
	}
	version, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(value, `W/"`), `"`), 10, 64)
	if err != nil || version < 1 {
		return 0, errors.New("invalid ETag")
	}
	return version, nil
}
