// Package identity: oidc.go implements the OIDC client (REQ-144,
// D-239, D-242, D-246). Orca uses OIDC for human-identity
// authentication. The bundled Dex (deployed by `orca auth init-idp`)
// is the default issuer; `oidc.issuer` in config can repoint to a BYO
// external IdP. The CLI performs the authorization-code + PKCE +
// local loopback redirect flow (`orca auth login`); headless/CI uses
// the device-code flow.
//
// R-021 invariant: Orca never issues, stores, or accepts human-identity
// credentials. The IdP issues tokens; Orca only stores them (short-
// lived, 0600, refreshable). No passwords, no Orca-issued tokens.
package identity

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"
)

// OIDCConfig holds the OIDC client configuration. It is loaded from
// the cluster config block (`oidc.issuer`, `client_id`, `client_secret`,
// `scopes`).
type OIDCConfig struct {
	Issuer       string   `json:"issuer"`
	ClientID     string   `json:"client_id"`
	ClientSecret string   `json:"client_secret,omitempty"`
	Scopes       []string `json:"scopes,omitempty"`
	// RedirectPort is the local loopback port for the auth-code flow.
	// 0 means ephemeral.
	RedirectPort int `json:"redirect_port,omitempty"`
}

// DefaultScopes returns the standard OIDC scopes Orca requests.
func DefaultScopes() []string {
	return []string{oidc.ScopeOpenID, "profile", "email", "groups"}
}

// Credentials is the on-disk token store at ~/.orca/credentials.json
// (0600). Short-lived ID token + refresh token. Refresh handles
// rotation; no long-lived Orca-issued tokens (the IdP issues them).
type Credentials struct {
	AccessToken  string    `json:"access_token,omitempty"`
	RefreshToken string    `json:"refresh_token,omitempty"`
	IDToken     string    `json:"id_token"`
	Expiry       time.Time `json:"expiry"`
	Issuer       string    `json:"issuer"`
	Subject      string    `json:"subject"`
	Groups       []string  `json:"groups,omitempty"`
}

// credentialsPath returns the on-disk credentials path (0600).
func credentialsPath() (string, error) {
	home := os.Getenv("ORCA_HOME")
	if home == "" {
		userHome, err := os.UserHomeDir()
		if err != nil {
			return "", fmt.Errorf("oidc: ORCA_HOME unset and home dir: %w", err)
		}
		home = filepath.Join(userHome, ".orca")
	}
	return filepath.Join(home, "credentials.json"), nil
}

// LoadCredentials reads the stored OIDC credentials (0600). Returns
// an error if the file is missing or has looser permissions.
func LoadCredentials() (*Credentials, error) {
	path, err := credentialsPath()
	if err != nil {
		return nil, err
	}
	info, err := os.Stat(path)
	if err != nil {
		return nil, fmt.Errorf("oidc: no credentials: %w", err)
	}
	if info.Mode().Perm()&0o077 != 0 {
		return nil, fmt.Errorf("oidc: credentials %s has mode %o, expected 0600", path, info.Mode().Perm())
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("oidc: read credentials: %w", err)
	}
	var c Credentials
	if err := json.Unmarshal(data, &c); err != nil {
		return nil, fmt.Errorf("oidc: parse credentials: %w", err)
	}
	return &c, nil
}

// SaveCredentials writes the OIDC credentials to disk at 0600.
func SaveCredentials(c *Credentials) error {
	path, err := credentialsPath()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return fmt.Errorf("oidc: mkdir: %w", err)
	}
	data, err := json.MarshalIndent(c, "", "  ")
	if err != nil {
		return fmt.Errorf("oidc: marshal: %w", err)
	}
	return writeAtomic0600(path, data)
}

// ClearCredentials removes the stored credentials (logout).
func ClearCredentials() error {
	path, err := credentialsPath()
	if err != nil {
		return err
	}
	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("oidc: clear credentials: %w", err)
	}
	return nil
}

// writeAtomic0600 writes data to path atomically at mode 0600
// (temp + chmod + rename).
func writeAtomic0600(path string, data []byte) error {
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, 0o600); err != nil {
		return fmt.Errorf("oidc: write tmp: %w", err)
	}
	return os.Rename(tmp, path)
}

// OIDCClient wraps the OIDC provider + oauth2 config for the auth flow.
type OIDCClient struct {
	provider *oidc.Provider
	oauth2   *oauth2.Config
	verifier *oidc.IDTokenVerifier
	cfg      OIDCConfig
}

// NewOIDCClient discovers the issuer and builds the client.
func NewOIDCClient(ctx context.Context, cfg OIDCConfig) (*OIDCClient, error) {
	if cfg.Issuer == "" {
		return nil, fmt.Errorf("oidc: issuer is empty")
	}
	if cfg.ClientID == "" {
		return nil, fmt.Errorf("oidc: client_id is empty")
	}
	provider, err := oidc.NewProvider(ctx, cfg.Issuer)
	if err != nil {
		return nil, fmt.Errorf("oidc: discover %s: %w", cfg.Issuer, err)
	}
	scopes := cfg.Scopes
	if len(scopes) == 0 {
		scopes = DefaultScopes()
	}
	oauthCfg := &oauth2.Config{
		ClientID:     cfg.ClientID,
		ClientSecret: cfg.ClientSecret,
		Endpoint:     provider.Endpoint(),
		Scopes:       scopes,
	}
	verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
	return &OIDCClient{
		provider: provider,
		oauth2:   oauthCfg,
		verifier: verifier,
		cfg:      cfg,
	}, nil
}

// pkcePair holds the PKCE verifier + challenge.
type pkcePair struct {
	verifier  string
	challenge string
}

func generatePKCE() (pkcePair, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return pkcePair{}, fmt.Errorf("oidc: pkce rand: %w", err)
	}
	verifier := base64.RawURLEncoding.EncodeToString(b)
	h := sha256.Sum256([]byte(verifier))
	challenge := base64.RawURLEncoding.EncodeToString(h[:])
	return pkcePair{verifier: verifier, challenge: challenge}, nil
}

// Login performs the authorization-code + PKCE + local loopback
// redirect flow. It opens a local HTTP server on an ephemeral port,
// builds the auth URL, and waits for the callback. The caller is
// responsible for opening the URL in a browser (the CLI does this).
// Returns the credentials after exchanging the code.
func (c *OIDCClient) Login(ctx context.Context, openBrowser func(string) error) (*Credentials, error) {
	pkce, err := generatePKCE()
	if err != nil {
		return nil, err
	}
	port := c.cfg.RedirectPort
	if port == 0 {
		port = 0
	}
	listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
	if err != nil {
		return nil, fmt.Errorf("oidc: listen: %w", err)
	}
	defer listener.Close()
	actualPort := listener.Addr().(*net.TCPAddr).Port
	redirectURL := fmt.Sprintf("http://127.0.0.1:%d/callback", actualPort)
	c.oauth2.RedirectURL = redirectURL

	state, err := randString(16)
	if err != nil {
		return nil, err
	}
	authURL := c.oauth2.AuthCodeURL(state,
		oauth2.SetAuthURLParam("code_challenge", pkce.challenge),
		oauth2.SetAuthURLParam("code_challenge_method", "S256"),
	)

	if openBrowser != nil {
		if err := openBrowser(authURL); err != nil {
			return nil, fmt.Errorf("oidc: open browser: %w", err)
		}
	}

	type result struct {
		code string
		err  error
	}
	resultCh := make(chan result, 1)
	srv := &http.Server{}
	srv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/callback" {
			http.NotFound(w, r)
			return
		}
		q := r.URL.Query()
		if errVal := q.Get("error"); errVal != "" {
			resultCh <- result{err: fmt.Errorf("oidc: auth error: %s", errVal)}
			fmt.Fprintf(w, "Authentication failed: %s. You can close this tab.", errVal)
			return
		}
		if q.Get("state") != state {
			resultCh <- result{err: fmt.Errorf("oidc: state mismatch")}
			http.Error(w, "state mismatch", http.StatusBadRequest)
			return
		}
		code := q.Get("code")
		if code == "" {
			resultCh <- result{err: fmt.Errorf("oidc: no code in callback")}
			http.Error(w, "missing code", http.StatusBadRequest)
			return
		}
		resultCh <- result{code: code}
		fmt.Fprintf(w, "Authentication successful. You can close this tab and return to the CLI.")
	})

	go srv.Serve(listener)
	defer srv.Shutdown(context.Background())

	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	case res := <-resultCh:
		if res.err != nil {
			return nil, res.err
		}
		token, err := c.oauth2.Exchange(ctx, res.code,
			oauth2.SetAuthURLParam("code_verifier", pkce.verifier),
		)
		if err != nil {
			return nil, fmt.Errorf("oidc: token exchange: %w", err)
		}
		return c.tokenToCredentials(token)
	}
}

// tokenToCredentials extracts the ID token, verifies it, and builds
// the Credentials struct.
func (c *OIDCClient) tokenToCredentials(token *oauth2.Token) (*Credentials, error) {
	rawID, ok := token.Extra("id_token").(string)
	if !ok || rawID == "" {
		return nil, fmt.Errorf("oidc: no id_token in token response")
	}
	idToken, err := c.verifier.Verify(context.Background(), rawID)
	if err != nil {
		return nil, fmt.Errorf("oidc: verify id_token: %w", err)
	}
	var claims struct {
		Groups []string `json:"groups"`
	}
	_ = idToken.Claims(&claims)
	return &Credentials{
		AccessToken:  token.AccessToken,
		RefreshToken: token.RefreshToken,
		IDToken:      rawID,
		Expiry:       token.Expiry,
		Issuer:       c.cfg.Issuer,
		Subject:      idToken.Subject,
		Groups:       claims.Groups,
	}, nil
}

// Refresh refreshes the credentials using the refresh token.
func (c *OIDCClient) Refresh(ctx context.Context, creds *Credentials) (*Credentials, error) {
	if creds.RefreshToken == "" {
		return nil, fmt.Errorf("oidc: no refresh token")
	}
	ts := c.oauth2.TokenSource(ctx, &oauth2.Token{
		RefreshToken: creds.RefreshToken,
	})
	token, err := ts.Token()
	if err != nil {
		return nil, fmt.Errorf("oidc: refresh: %w", err)
	}
	return c.tokenToCredentials(token)
}

// VerifyIDToken verifies an ID token string against the issuer's JWKS.
// Returns the verified claims (subject, issuer, expiry, groups).
func (c *OIDCClient) VerifyIDToken(ctx context.Context, rawID string) (*IDTokenClaims, error) {
	idToken, err := c.verifier.Verify(ctx, rawID)
	if err != nil {
		return nil, fmt.Errorf("oidc: verify: %w", err)
	}
	var claims IDTokenClaims
	if err := idToken.Claims(&claims); err != nil {
		return nil, fmt.Errorf("oidc: parse claims: %w", err)
	}
	claims.Expiry = idToken.Expiry
	return &claims, nil
}

// IDTokenClaims holds the verified OIDC ID token claims used by Orca.
type IDTokenClaims struct {
	Subject string    `json:"sub"`
	Issuer  string    `json:"iss"`
	Groups  []string  `json:"groups,omitempty"`
	Email   string    `json:"email,omitempty"`
	Expiry  time.Time `json:"-"`
}

// VerifyIDTokenStatic is a standalone verifier that doesn't require
// a long-lived OIDCClient. It discovers the issuer, verifies the
// token, and returns the claims. Used by the SSH-push applier (which
// validates the ORCA_OIDC_TOKEN env var before applying any txn).
func VerifyIDTokenStatic(ctx context.Context, issuer, clientID, rawID string) (*IDTokenClaims, error) {
	provider, err := oidc.NewProvider(ctx, issuer)
	if err != nil {
		return nil, fmt.Errorf("oidc: discover %s: %w", issuer, err)
	}
	verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
	idToken, err := verifier.Verify(ctx, rawID)
	if err != nil {
		return nil, fmt.Errorf("oidc: verify: %w", err)
	}
	var claims IDTokenClaims
	if err := idToken.Claims(&claims); err != nil {
		return nil, fmt.Errorf("oidc: parse claims: %w", err)
	}
	claims.Expiry = idToken.Expiry
	return &claims, nil
}

// randString generates a URL-safe random string of n bytes.
func randString(n int) (string, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

// DiscoverDeviceFlow checks if the issuer supports the device-code
// grant (OIDC device flow). Returns the device endpoint URL if
// supported. Used by the headless/CI fallback (D-245).
func DiscoverDeviceFlow(ctx context.Context, issuer string) (deviceAuthURL string, tokenURL string, err error) {
	provider, err := oidc.NewProvider(ctx, issuer)
	if err != nil {
		return "", "", fmt.Errorf("oidc: discover: %w", err)
	}
	var claims struct {
		DeviceAuth string `json:"device_authorization_endpoint"`
	}
	if err := provider.Claims(&claims); err != nil {
		return "", "", fmt.Errorf("oidc: claims: %w", err)
	}
	if claims.DeviceAuth == "" {
		return "", "", fmt.Errorf("oidc: issuer %s does not support device flow", issuer)
	}
	return claims.DeviceAuth, provider.Endpoint().TokenURL, nil
}

// DeviceFlowLogin performs the device-code flow (headless/CI).
// It requests a device code, prints the user URL + code to the
// provided writer, and polls for the token. Returns the credentials.
func (c *OIDCClient) DeviceFlowLogin(ctx context.Context, w io.Writer) (*Credentials, error) {
	deviceAuthURL, tokenURL, err := DiscoverDeviceFlow(ctx, c.cfg.Issuer)
	if err != nil {
		return nil, err
	}
	form := url.Values{}
	form.Set("client_id", c.cfg.ClientID)
	if c.cfg.ClientSecret != "" {
		form.Set("client_secret", c.cfg.ClientSecret)
	}
	resp, err := http.PostForm(deviceAuthURL, form)
	if err != nil {
		return nil, fmt.Errorf("oidc: device auth request: %w", err)
	}
	defer resp.Body.Close()
	var dr struct {
		DeviceCode      string `json:"device_code"`
		UserCode        string `json:"user_code"`
		VerificationURI string `json:"verification_uri"`
		Interval        int    `json:"interval"`
		ExpiresIn       int    `json:"expires_in"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&dr); err != nil {
		return nil, fmt.Errorf("oidc: device auth decode: %w", err)
	}
	if dr.Interval == 0 {
		dr.Interval = 5
	}
	fmt.Fprintf(w, "Open %s and enter code: %s\n", dr.VerificationURI, dr.UserCode)

	deadline := time.Now().Add(time.Duration(dr.ExpiresIn) * time.Second)
	interval := time.Duration(dr.Interval) * time.Second
	for time.Now().Before(deadline) {
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-time.After(interval):
		}
		tform := url.Values{}
		tform.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
		tform.Set("device_code", dr.DeviceCode)
		tform.Set("client_id", c.cfg.ClientID)
		if c.cfg.ClientSecret != "" {
			tform.Set("client_secret", c.cfg.ClientSecret)
		}
		tresp, err := http.PostForm(tokenURL, tform)
		if err != nil {
			continue
		}
		var tr struct {
			AccessToken  string `json:"access_token"`
			RefreshToken string `json:"refresh_token"`
			IDToken      string `json:"id_token"`
			ExpiresIn    int    `json:"expires_in"`
			Error        string `json:"error"`
		}
		json.NewDecoder(tresp.Body).Decode(&tr)
		tresp.Body.Close()
		if tr.Error == "authorization_pending" || tr.Error == "slow_down" {
			if tr.Error == "slow_down" {
				interval += 5 * time.Second
			}
			continue
		}
		if tr.Error != "" {
			return nil, fmt.Errorf("oidc: device flow: %s", tr.Error)
		}
		if tr.IDToken == "" {
			continue
		}
		token := &oauth2.Token{
			AccessToken:  tr.AccessToken,
			RefreshToken: tr.RefreshToken,
			Expiry:       time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second),
		}
		token = token.WithExtra(map[string]any{"id_token": tr.IDToken})
		return c.tokenToCredentials(token)
	}
	return nil, fmt.Errorf("oidc: device flow timed out")
}

// Issuer returns the configured issuer URL.
func (c *OIDCClient) Issuer() string { return c.cfg.Issuer }

// Ensure no unused import for strings (used in error formatting).
var _ = strings.Contains
