package oauth

import (
	"context"
	"errors"
	"log/slog"
	"net/http"
	"os"
	"sync"
	"time"

	"golang.org/x/oauth2"
)

// DefaultAuthTimeout bounds how long a single authorization attempt waits for
// the user to complete the browser or device flow.
const DefaultAuthTimeout = 5 * time.Minute

// tokenRefreshTimeout bounds each background refresh of an expiring token so a
// stalled GitHub token endpoint cannot block a tool call indefinitely.
const tokenRefreshTimeout = 30 * time.Second

// flowStatus tracks the manager's single-flight authorization state.
type flowStatus int

const (
	statusIdle         flowStatus = iota // no flow running
	statusStarting                       // a flow is being prepared (brief)
	statusInProgress                     // a flow is running on a secure channel; callers may join
	statusAwaitingUser                   // a flow is running but the user must act out-of-band
)

// Outcome reports the result of an authorization attempt that did not
// immediately yield a token.
type Outcome struct {
	// UserAction, when non-nil, must be surfaced to the user. The authorization
	// flow continues in the background; the user should retry once they have
	// completed it.
	UserAction *UserAction
}

// UserAction is an instruction for the user to complete authorization out of
// band (the last-resort channel, used when neither a browser nor URL
// elicitation is available).
type UserAction struct {
	// Message is ready to display to the user.
	Message string
	// URL is the authorization URL or device verification URI.
	URL string
	// UserCode is the device-flow code to enter, if any.
	UserCode string
}

// Manager owns the OAuth login flows and the resulting (refreshing) token for a
// single stdio session. It is safe for concurrent use; only one authorization
// flow runs at a time.
type Manager struct {
	config        Config
	refreshConfig *oauth2.Config
	logger        *slog.Logger

	// Test seams, set by NewManager to real implementations.
	openURL  func(string) error
	inDocker func() bool

	mu               sync.Mutex
	source           oauth2.TokenSource // refreshing source, set once authorized
	status           flowStatus
	pending          *UserAction
	done             chan struct{}
	lastErr          error
	refreshErrLogged bool // true once a refresh failure has been logged, reset on re-auth
}

// NewManager builds a Manager for the given configuration. A nil logger logs to
// stderr.
func NewManager(cfg Config, logger *slog.Logger) *Manager {
	if logger == nil {
		logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
	}
	m := &Manager{
		config:   cfg,
		logger:   logger,
		openURL:  openBrowser,
		inDocker: isRunningInDocker,
	}
	m.refreshConfig = m.oauth2Config("")
	return m
}

// AccessToken returns a currently valid access token, refreshing it if needed,
// or "" if the session is not authorized (or a refresh has failed and
// re-authorization is required). It is cheap to call repeatedly: the underlying
// token source caches and only refreshes when the token has expired.
func (m *Manager) AccessToken() string {
	m.mu.Lock()
	src := m.source
	m.mu.Unlock()
	if src == nil {
		return ""
	}
	// Refresh (if needed) happens here, off the lock, because ReuseTokenSource may
	// make a blocking network call and holding m.mu would serialize every tool call.
	tok, err := src.Token()
	if err != nil {
		// A refresh failure (expired GitHub App refresh token, revoked grant, or a
		// network blip) leaves the session unauthorized and forces a re-login.
		// Surface it once, otherwise it only manifests as a surprise re-authorization
		// prompt. The oauth2 error carries the token endpoint's response, not the
		// access or refresh token.
		m.mu.Lock()
		if !m.refreshErrLogged {
			m.refreshErrLogged = true
			m.logger.Warn("OAuth token refresh failed; re-authorization required", "error", err)
		}
		m.mu.Unlock()
		return ""
	}
	if !tok.Valid() {
		return ""
	}
	return tok.AccessToken
}

// HasToken reports whether a valid token is currently available.
func (m *Manager) HasToken() bool {
	return m.AccessToken() != ""
}

// Authenticate ensures the session is authorized.
//
// It returns (nil, nil) once a token is available, so the caller may proceed.
// It returns (&Outcome{UserAction}, nil) when the user must complete the flow
// out of band; the flow continues in the background and the caller should show
// the action and have the user retry. It returns (nil, err) on failure.
//
// Only one flow runs at a time. Concurrent callers either join a running secure
// flow, receive the pending user action, or are told to retry shortly.
func (m *Manager) Authenticate(ctx context.Context, prompter Prompter) (*Outcome, error) {
	if m.AccessToken() != "" {
		return nil, nil
	}

	m.mu.Lock()
	switch m.status {
	case statusAwaitingUser:
		ua := m.pending
		m.mu.Unlock()
		return &Outcome{UserAction: ua}, nil
	case statusStarting:
		m.mu.Unlock()
		return &Outcome{UserAction: &UserAction{
			Message: "GitHub authorization is already in progress. Please retry your request in a few seconds.",
		}}, nil
	case statusInProgress:
		done := m.done
		m.mu.Unlock()
		return m.joinWait(ctx, done)
	}

	// Idle: this call owns the new flow.
	m.status = statusStarting
	m.lastErr = nil
	m.done = make(chan struct{})
	done := m.done
	m.mu.Unlock()

	plan, err := m.begin(prompter)
	if err != nil {
		m.complete(nil, err)
		return nil, err
	}

	m.mu.Lock()
	if plan.userAction != nil {
		m.status = statusAwaitingUser
		m.pending = plan.userAction
	} else {
		m.status = statusInProgress
	}
	m.mu.Unlock()

	bgCtx, cancel := context.WithTimeout(context.Background(), DefaultAuthTimeout)
	go m.runFlow(bgCtx, cancel, plan)

	if plan.userAction != nil {
		return &Outcome{UserAction: plan.userAction}, nil
	}
	return m.joinWait(ctx, done)
}

// runFlow executes a prepared flow in the background and records the result. The
// optional display prompt runs concurrently: a decline (or other failure) aborts
// the flow, while an undeliverable prompt degrades to the manual fallback without
// tearing the flow down, so the user can still authorize out of band.
func (m *Manager) runFlow(ctx context.Context, cancel context.CancelFunc, plan *flowPlan) {
	defer cancel()

	if plan.display != nil {
		go func() {
			err := plan.display(ctx)
			switch {
			case err == nil:
				// Prompt shown; the flow completes when the token arrives.
			case ctx.Err() != nil:
				// The flow is already ending (timed out or cancelled elsewhere),
				// so there is nothing to fall back to. Checking this before the
				// fallback also prevents misreading a context-cancelled prompt as
				// a transport failure.
			case errors.Is(err, ErrPromptUnavailable) && plan.fallback != nil:
				// The client advertised the capability but could not deliver the
				// prompt. Surface the manual instructions instead of failing, and
				// keep the background flow alive so the user can still authorize.
				m.logger.Debug("authorization prompt undeliverable; falling back to manual instructions", "reason", err)
				m.fallBackToUserAction(plan.fallback)
			default:
				// A user decline (ErrPromptDeclined) or any other prompt failure
				// ends the flow.
				m.logger.Debug("authorization prompt closed", "reason", err)
				cancel()
			}
		}()
	}

	tok, err := plan.run(ctx)
	m.complete(tok, err)
}

// fallBackToUserAction promotes a running secure flow to the manual user-action
// channel after its prompt could not be delivered. The background flow keeps
// running, so the user can complete authorization out of band and retry. It is a
// no-op if the flow has already resolved.
func (m *Manager) fallBackToUserAction(ua *UserAction) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.status != statusInProgress {
		return
	}
	m.status = statusAwaitingUser
	m.pending = ua
	// Wake any callers joined on this flow so they receive the action, and clear
	// done so complete() does not double-close it when run() later finishes.
	if m.done != nil {
		close(m.done)
		m.done = nil
	}
}

// complete records the flow result, installing a refreshing token source on
// success, and wakes any joined callers.
func (m *Manager) complete(tok *oauth2.Token, err error) {
	m.mu.Lock()
	defer m.mu.Unlock()

	m.status = statusIdle
	m.pending = nil
	if err != nil {
		m.lastErr = err
		m.logger.Debug("oauth flow failed", "error", err)
	} else {
		m.lastErr = nil
		// Config.TokenSource returns a ReuseTokenSource that refreshes expired
		// tokens using the refresh token — this is what makes GitHub App
		// (expiring) tokens work transparently. The refresh uses a bounded HTTP
		// client so a stalled token endpoint can't block a tool call forever.
		refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Timeout: tokenRefreshTimeout})
		m.source = m.refreshConfig.TokenSource(refreshCtx, tok)
		m.refreshErrLogged = false
		m.logger.Info("github authorization complete")
	}
	if m.done != nil {
		close(m.done)
		m.done = nil
	}
}

// joinWait blocks until the running flow finishes or ctx is cancelled. If the
// flow was promoted to the manual channel while waiting (its prompt could not be
// delivered), it returns that user action rather than an error.
func (m *Manager) joinWait(ctx context.Context, done chan struct{}) (*Outcome, error) {
	select {
	case <-done:
		if m.AccessToken() != "" {
			return nil, nil
		}
		m.mu.Lock()
		pending := m.pending
		err := m.lastErr
		m.mu.Unlock()
		if pending != nil {
			return &Outcome{UserAction: pending}, nil
		}
		if err != nil {
			return nil, err
		}
		return nil, errors.New("authorization did not complete")
	case <-ctx.Done():
		return nil, ctx.Err()
	}
}

func (m *Manager) oauth2Config(redirectURL string) *oauth2.Config {
	return &oauth2.Config{
		ClientID:     m.config.ClientID,
		ClientSecret: m.config.ClientSecret,
		RedirectURL:  redirectURL,
		Scopes:       m.config.Scopes,
		Endpoint:     m.config.Endpoint,
	}
}
