package auth

import (
	"context"
	"strings"
	"time"

	"github.com/nexuscrm/kernel/pkg/auth"
	"github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/shared/pkg/models"
)

// browserSessionLookupRepository is a second auth-owned optional capability,
// kept separate from sessionControlRepository for the same reason that one is
// kept out of ports.SessionRepository: adding a method to an interface other
// packages already implement would break their compatibility for a capability
// they do not provide.
type browserSessionLookupRepository interface {
	FindSessionByControlProofHash(ctx context.Context, controlProofHash string) (*models.SystemSession, error)
}

// BrowserSessionValidation is the result of authenticating a first-party browser
// session from its session-control proof alone.
//
// It carries no bearer and no proof. The browser lane exists so the page holds no
// readable credential, so there is nothing here to hand back to it; the caller
// reissues the cookie it already received from the request when ExpiresAt moves.
type BrowserSessionValidation struct {
	User      auth.UserSession
	SessionID string
	AuthEpoch uint64
	ExpiresAt time.Time
}

// ValidateBrowserSession resolves and validates a durable browser session from the
// raw session-control proof, with no bearer token involved.
//
// The lookup path is proof -> SHA-256 verifier -> indexed durable session, replacing
// bearer -> JWT session id -> compare-proof. Every acceptance decision is delegated:
//
//   - the verifier's shape is checked by the same helper the bearer path uses, so a
//     malformed proof is rejected before any query;
//   - the row is selected by SessionRepository.FindSessionByControlProofHash, which
//     fails closed when two sessions share a verifier rather than choosing one;
//   - the stored verifier is re-compared in constant time. The database already
//     matched it, so this is defence in depth: it is what keeps a case-folding
//     collation on the verifier column from admitting a near-miss, a state the
//     control-plane readiness check reports but does not enforce;
//   - revocation, expiry and user state come from validateDurableSession, the one
//     authoritative owner, so this does not become a second place that decides
//     whether a session is acceptable.
//
// The projected session deliberately carries the durable row's Scope, GrantType and
// ConnectedAppID unchanged. Blanking them would be the dangerous move rather than
// the safe one: an empty Scope currently means full access, so clearing it would
// promote a browser session to unrestricted API authority. A browser session is
// distinguished by the credential class the admission owner already selected — the
// browser lane — not by mutating this projection. Design section 11 owns the scope
// rule and section 13 owns the recorded class.
//
// Nothing calls this yet.
func (s *AuthService) ValidateBrowserSession(ctx context.Context, controlProof string) (*BrowserSessionValidation, error) {
	if s == nil {
		return nil, errors.NewUnauthorizedError("Invalid session")
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}

	lookup, ok := s.sessionRepo.(browserSessionLookupRepository)
	if !ok {
		// A deployment whose session repository cannot resolve a proof has no
		// browser lane at all. Fail closed rather than falling back to a bearer
		// path the browser is not supposed to have.
		return nil, errors.NewUnauthorizedError("Invalid session")
	}

	verifier, valid := presentedSessionControlProofHash(strings.TrimSpace(controlProof))
	if !valid {
		return nil, errors.NewUnauthorizedError("Invalid session")
	}

	candidate, err := lookup.FindSessionByControlProofHash(ctx, verifier)
	if err != nil {
		if ctxErr := ctx.Err(); ctxErr != nil {
			return nil, ctxErr
		}
		// A store that cannot answer is an outage, not a verdict on the proof. Reporting
		// it as an invalid session signs the browser out during an incident — and because
		// the page holds no other credential, it cannot recover by retrying with one.
		// The distinction is invisible to the caller either way: both answers deny the
		// request, and only the classification the transport projects differs.
		//
		// An ambiguous verifier is included here rather than treated as a denial. It
		// requires a SHA-256 collision on two live proofs, so it means the store is
		// returning something incoherent, which is the same class as being unable to
		// answer at all.
		return nil, newSecurityAuthorityUnavailable(err)
	}
	if candidate == nil || !sessionControlProofHashesEqual(verifier, candidate.ControlProofHash) {
		return nil, errors.NewUnauthorizedError("Invalid session")
	}

	session, user, err := s.validateDurableSession(ctx, candidate.ID, false)
	if err != nil {
		return nil, err
	}
	// The proof selected a row; the authoritative read is by id. If they disagree
	// the state changed underneath, and the safe answer is no principal.
	if session.ID != candidate.ID {
		return nil, errors.NewUnauthorizedError("Invalid session")
	}
	if session.AuthEpoch == 0 {
		return nil, errors.NewUnauthorizedError("Invalid session")
	}

	// buildUserSessionForUser is the same constructor the login path uses, so the
	// browser lane gets the identical identity and display data: display name,
	// email, profile name and role name. projectSelectedRootUserSession was the
	// wrong owner here — it populates ids only, which would have returned an
	// identity-less session to /current and stripped the session-bound formula
	// context.
	projected, err := s.buildUserSessionForUser(ctx, user.ID)
	if err != nil {
		return nil, err
	}
	// These three assignments are load-bearing and pinned by
	// TestBrowserSessionProjectionKeepsProgrammaticFactsUnchanged: blanking Scope
	// would promote this session to full API authority, because an empty scope
	// currently means full access.
	projected.ConnectedAppId = durableSessionString(session.ConnectedAppID)
	projected.Scope = durableSessionString(session.Scope)
	projected.GrantType = durableSessionString(session.GrantType)
	// SessionClass is a bearer-owned admission input and is not authority by
	// itself, so the browser lane does not claim one here.
	projected.SessionClass = ""

	return &BrowserSessionValidation{
		User:      projected,
		SessionID: session.ID,
		AuthEpoch: session.AuthEpoch,
		ExpiresAt: session.ExpiresAt,
	}, nil
}

// RevokeBrowserSession revokes the durable session a browser proof identifies.
//
// The two non-error results mean different things and the transport depends on the
// difference:
//
//   - (false, nil) means there is provably nothing to revoke. A missing, malformed,
//     unknown or already-revoked proof grants no principal, so no durable session can
//     outlive the response. Design section 10 requires the same expired-cookie answer
//     for these as for a successful logout — a browser holding a corrupt HttpOnly
//     cookie has no other way to get rid of it, and distinguishing them would turn
//     logout into an oracle for whether a proof was ever valid;
//   - a non-nil error means revocation could not be determined or could not be
//     completed. The session may well still be live, so the transport must not tell
//     the user they are signed out.
//
// Collapsing the second case into the first is not a smaller version of the same
// answer, it is the opposite one: the browser loses its proof while the session it
// identified stays usable, which is the theft window this lane exists to close. That
// leaks nothing either way, because an infrastructure failure is independent of the
// presented proof — a valid and an invalid proof both fail when the store is down.
//
// An ambiguous verifier is propagated rather than special-cased. It requires a
// SHA-256 collision on two live proofs, so treating it as "nothing to revoke" would
// buy nothing and would need a dependency edge on the persistence sentinel to say so.
//
// The revocation itself is compare-and-swap on the verifier, so a stale proof cannot
// revoke a session that has since rotated its credential. Only the verifier is sent
// to persistence; the raw proof never leaves this function.
//
// Nothing calls this yet.
func (s *AuthService) RevokeBrowserSession(ctx context.Context, controlProof string) (bool, error) {
	// The next three are inability to act, not proof that nothing needs revoking.
	// Reporting them as (false, nil) would make a deployment wired without a
	// proof-lookup repository silently skip every revocation it was ever asked for,
	// while each logout answered as though it had succeeded.
	if s == nil {
		return false, errors.NewInternalError("browser session revocation is unavailable", nil)
	}
	if err := ctx.Err(); err != nil {
		return false, err
	}
	if s.sessionControlRepo == nil {
		return false, errors.NewInternalError("browser session revocation is unavailable", nil)
	}
	lookup, ok := s.sessionRepo.(browserSessionLookupRepository)
	if !ok {
		return false, errors.NewInternalError("browser session revocation is unavailable", nil)
	}

	verifier, valid := presentedSessionControlProofHash(strings.TrimSpace(controlProof))
	if !valid {
		// Nothing with this shape can identify a session, so nothing can survive.
		return false, nil
	}

	candidate, err := lookup.FindSessionByControlProofHash(ctx, verifier)
	if err != nil {
		return false, err
	}
	if candidate == nil || !sessionControlProofHashesEqual(verifier, candidate.ControlProofHash) {
		return false, nil
	}

	return s.sessionControlRepo.RevokeSessionWithControlProof(ctx, candidate.ID, verifier)
}
