package auth

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"strings"
	"time"

	"github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/ports"
	semantic "github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/federatedloginv1"
)

// ssoLoginTransactionTTL bounds how long an SSO login may stay in flight.
//
// Long enough for a real person to complete a provider's consent screen, short enough that a
// captured state is worthless by the time it could be replayed at leisure. It is deliberately
// shorter than the authorize-request handle's lifetime: that one survives a full login detour,
// while this one only spans a redirect the user is already in the middle of.
const ssoLoginTransactionTTL = 10 * time.Minute

// ssoLoginStatePrefix namespaces only the durable store key. The browser-visible
// state remains the contract's canonical 43-character base64url value, while a
// state submitted to this flow can address only the SSO namespace in the shared
// authorize-request store.
//
// The first version tagged the stored row's Issuer column instead, and that was wrong twice over.
// Issuer carries a real meaning for the authorize flow, so overloading it made the row lie; and the
// tag could only be checked *after* the row had been fetched, which meant an authorize handle
// submitted here was consumed — permanently spent — before being rejected. An unauthenticated caller
// could therefore burn somebody else's pending OAuth login by guessing nothing at all: just replaying
// a handle it had seen.
//
// Namespacing the durable key removes the whole class. Every browser value is
// mapped into this flow's prefix before a lookup, so no other flow's row can be
// reached, let alone spent.
const ssoLoginStatePrefix = "ssologin."

// SSOLoginTransaction is the one-time expiring state a browser SSO login is bound to.
//
// Design section 9 requires this binding. Until now the state existed only in the page's
// sessionStorage — the frontend generated a UUID and compared it on callback — so the server had no
// way to tell a state it issued from one an attacker chose. Transport-level CSRF and the
// session-control intent header already block the classic cross-origin login-CSRF vector, which is
// why this is defence in depth rather than the only fence; but "the page checks it against itself"
// is not a binding, and an attacker with any foothold in the origin was unconstrained by it.
type SSOLoginTransaction struct {
	// State is the opaque value the browser hands to the identity provider. It is returned in
	// the provider's redirect, so it is not a secret — its value is that the server issued it,
	// remembers it exactly once, and forgets it quickly.
	State     string
	ExpiresAt time.Time
}

// BeginSSOLoginTransaction issues a one-time expiring state for a browser SSO login.
//
// It reuses the pre-login authorize-request store rather than adding one. That store already has
// the three properties this needs and they are the hard parts: only the handle's hash is persisted,
// rows expire, and consumption is a single-use compare-and-set. Its own description is "pre-login
// OAuth authorize request snapshots for browser login/resume", and an SSO login is a pre-login
// browser login transaction.
//
// It does not go through CreateAuthorizeRequestHandle, which requires a public Connected App and
// validates PKCE. Neither exists for "log in with Google": the OAuth client in that exchange is the
// provider's, and NexusCRM is not issuing an authorization code. Forcing this through that entry
// point would mean inventing a client and a code challenge to satisfy checks that do not apply.
//
// The redirect URI is stored so completion can require the same one the login started with. A state
// that is valid for any redirect target is a state that can be replayed into a different one.
func (s *OAuthService) BeginSSOLoginTransaction(
	ctx context.Context,
	redirectURI string,
) (*SSOLoginTransaction, error) {
	if s == nil || s.oauthRepo == nil {
		return nil, errors.NewInternalError("SSO login transaction storage is unavailable", nil)
	}
	redirectURI = strings.TrimSpace(redirectURI)
	if redirectURI == "" {
		return nil, errors.NewValidationError("redirect_uri", "redirect_uri is required to begin an SSO login")
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}

	entropy := make([]byte, semantic.StateEntropyBytes)
	if _, err := rand.Read(entropy); err != nil {
		return nil, errors.NewInternalError("failed to generate SSO login state", err)
	}
	state := base64.RawURLEncoding.EncodeToString(entropy)
	expiresAt := time.Now().Add(ssoLoginTransactionTTL)
	// The semantic contract deliberately leaves cryptographic generation in
	// the Runtime owner, but remains the authority for the canonical public
	// representation. Fail closed before persisting if the two ever drift.
	if err := semantic.ValidateBeginResponse(semantic.BeginResponse{
		State: state, ExpiresAtUnixSecond: expiresAt.Unix(),
	}); err != nil {
		return nil, errors.NewInternalError("generated SSO login state violates the Runtime contract", err)
	}
	if err := s.oauthRepo.StoreAuthorizeRequest(ctx, ports.OAuthAuthorizeRequestCreateRequest{
		Handle:      ssoLoginStorageHandle(state),
		RedirectURI: redirectURI,
		// The built-in browser client is the honest answer here, not a placeholder: this
		// transaction exists to log a user into the first-party UI, and recording anything else
		// would make the row claim a client that took no part in it.
		ClientID: BuiltInBrowserClientID,
		// The deployment's own redirect target is the honest issuer for a login it is hosting.
		// Nothing decides on this value — the flow is identified by the handle's namespace — so
		// recording anything invented here would only make the row misleading.
		Issuer: redirectURI,
		// PKCE does not apply: NexusCRM issues no authorization code in this exchange.
		CodeChallenge:       "",
		CodeChallengeMethod: "",
		ExpiresAt:           expiresAt,
	}); err != nil {
		return nil, err
	}
	return &SSOLoginTransaction{State: state, ExpiresAt: expiresAt}, nil
}

// CompleteSSOLoginTransaction consumes the state a browser SSO login was bound to.
//
// Consumption is the point. A state that is merely *checked* can be used twice — once by the victim
// and once by whoever captured the provider's redirect — so this must be the compare-and-set the
// store already implements, not a read followed by a decision.
//
// Every failure is one error. A caller learning whether a state was unknown, expired or already
// spent learns which of its guesses came closest, and the answer is the same in all three cases
// anyway: start the login again.
func (s *OAuthService) CompleteSSOLoginTransaction(
	ctx context.Context,
	state string,
	redirectURI string,
) error {
	if s == nil || s.oauthRepo == nil {
		return errors.NewInternalError("SSO login transaction storage is unavailable", nil)
	}
	state = strings.TrimSpace(state)
	redirectURI = strings.TrimSpace(redirectURI)
	if state == "" || redirectURI == "" {
		return errors.NewUnauthorizedError("SSO login state is invalid")
	}
	if err := ctx.Err(); err != nil {
		return err
	}
	storageHandle := ssoLoginStorageHandle(state)

	// Read without consuming, so a request this function is going to reject anyway does not spend
	// the state. Only a state that is ours *and* matches its redirect gets consumed — otherwise a
	// rejected or transiently invalid attempt would burn a login the user could not then retry.
	stored, err := s.oauthRepo.GetAuthorizeRequest(ctx, storageHandle)
	if err != nil {
		// Distinguished from a rejection on purpose: the store failing is an outage, and
		// reporting it as an invalid state would tell a user their login is broken when the
		// truthful answer is "try again".
		return err
	}
	if stored == nil {
		// L1 projects the old browser-visible ssologin.<padded-base64> value
		// into the neutral canonical state before calling Runtime. During the
		// old row's remaining TTL, fall back to its exact durable handle only
		// when the canonical state decodes to the required entropy size.
		if legacyHandle := legacySSOLoginStorageHandle(state); legacyHandle != "" {
			stored, err = s.oauthRepo.GetAuthorizeRequest(ctx, legacyHandle)
			if err != nil {
				return err
			}
			if stored != nil {
				storageHandle = legacyHandle
			}
		}
	}
	if stored == nil {
		return errors.NewUnauthorizedError("SSO login state is invalid")
	}
	// The same redirect the login began with. A state accepted for any target can be replayed
	// into a different one, which is most of what binding is for.
	if strings.TrimSpace(stored.RedirectURI) != redirectURI {
		return errors.NewUnauthorizedError("SSO login state is invalid")
	}

	// Now spend it. Single use is the store's compare-and-set under FOR UPDATE, not this read: two
	// requests racing here both pass the checks above and exactly one consumes.
	consumed, err := s.oauthRepo.ConsumeAuthorizeRequest(ctx, storageHandle)
	if err != nil {
		return err
	}
	if consumed == nil {
		// Lost the race, or it expired between the two statements. Either way it is spent.
		return errors.NewUnauthorizedError("SSO login state is invalid")
	}
	return nil
}

func ssoLoginStorageHandle(state string) string {
	// Before the provider-neutral wire contract, the public state already
	// carried this namespace and the store used it unchanged. Keep those
	// in-flight rows consumable for their remaining TTL during a rolling
	// upgrade, but recognize only the exact old 32-byte padded-base64 shape;
	// an arbitrary prefixed input must not choose a shared-store handle.
	if legacySSOLoginState(state) {
		return state
	}
	return ssoLoginStatePrefix + state
}

func legacySSOLoginState(state string) bool {
	_, ok := CanonicalLegacySSOLoginState(state)
	return ok
}

// CanonicalLegacySSOLoginState recognizes only the exact browser-visible state
// shape issued before the provider-neutral Runtime contract and returns its
// canonical contract projection. It exists solely for the transaction TTL of
// a rolling upgrade; arbitrary prefixed values are never admitted.
func CanonicalLegacySSOLoginState(state string) (string, bool) {
	entropy, found := strings.CutPrefix(state, ssoLoginStatePrefix)
	if !found {
		return "", false
	}
	decoded, err := base64.URLEncoding.DecodeString(entropy)
	if err != nil || len(decoded) != semantic.StateEntropyBytes ||
		base64.URLEncoding.EncodeToString(decoded) != entropy {
		return "", false
	}
	return base64.RawURLEncoding.EncodeToString(decoded), true
}

func legacySSOLoginStorageHandle(state string) string {
	decoded, err := base64.RawURLEncoding.DecodeString(state)
	if err != nil || len(decoded) != semantic.StateEntropyBytes ||
		base64.RawURLEncoding.EncodeToString(decoded) != state {
		return ""
	}
	return ssoLoginStatePrefix + base64.URLEncoding.EncodeToString(decoded)
}
