// Package vkid completes VK ID web authorization on the server. A browser linking a
// VK identity has no signed Mini App launch parameters (that offline HMAC path is
// vkauth); instead the frontend runs the VK ID raw OAuth 2.1 flow (PKCE) and hands the
// gateway the authorization code, which the gateway exchanges — confidentially, under
// the app's protected key — for the launching user's trusted VK id. Unlike the Mini App
// path this makes an outbound call to VK (there is no offline verification for the web
// flow). See docs/ARCHITECTURE.md §12.
package vkid

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"
)

const (
	// tokenEndpoint is VK ID's OAuth 2.1 token endpoint. The frontend obtains the
	// authorization code against the same host, so the confidential exchange targets it
	// too. It is a fixed constant (not user input), so the outbound request carries no
	// SSRF risk.
	tokenEndpoint = "https://id.vk.com/oauth2/auth"
	// exchangeTimeout bounds one code-for-token exchange. Linking is interactive, so an
	// unreachable VK must fail fast rather than hold the request open.
	exchangeTimeout = 10 * time.Second
	// maxResponseBytes caps the token-response read to bound memory on an oversized body.
	maxResponseBytes = 1 << 16
)

// ErrInvalid reports an authorization fault: the exchange was rejected or yielded no vk
// user id (a bad or expired code, a mismatched verifier or redirect). It is distinct
// from a transport failure reaching VK, which surfaces as a wrapped error.
var ErrInvalid = errors.New("vkid: vk id authorization exchange failed")

// Identity is the user resolved from a completed VK ID exchange. ExternalID is the vk
// user id, used as the identities external_id.
type Identity struct {
	ExternalID string
}

// numericID accepts a VK user id that the token endpoint returns inconsistently as a
// JSON string or a JSON number, normalising both to their decimal string form (empty
// for a null or absent field).
type numericID string

func (n *numericID) UnmarshalJSON(b []byte) error {
	s := strings.Trim(string(b), `"`)
	if s == "null" {
		s = ""
	}
	*n = numericID(s)
	return nil
}

// Exchanger completes VK ID confidential authorization-code exchanges for one app.
type Exchanger struct {
	appID        string
	clientSecret string
	redirectURI  string
	endpoint     string
	httpClient   *http.Client
}

// New constructs an Exchanger for the app credentials. redirectURI must equal the
// trusted redirect URL registered with the app and the one the frontend used, or VK
// rejects the exchange.
func New(appID, clientSecret, redirectURI string) *Exchanger {
	return &Exchanger{
		appID:        appID,
		clientSecret: clientSecret,
		redirectURI:  redirectURI,
		endpoint:     tokenEndpoint,
		httpClient:   &http.Client{Timeout: exchangeTimeout},
	}
}

// Exchange completes the authorization-code grant and returns the trusted vk user id.
// code, deviceID and codeVerifier are the PKCE inputs the frontend obtained from the VK
// ID authorization redirect; the exchange authenticates with the app's protected key.
func (e *Exchanger) Exchange(ctx context.Context, code, deviceID, codeVerifier string) (Identity, error) {
	if code == "" || deviceID == "" || codeVerifier == "" {
		return Identity{}, ErrInvalid
	}
	form := url.Values{
		"grant_type":    {"authorization_code"},
		"code":          {code},
		"code_verifier": {codeVerifier},
		"device_id":     {deviceID},
		"client_id":     {e.appID},
		"client_secret": {e.clientSecret},
		"redirect_uri":  {e.redirectURI},
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.endpoint, strings.NewReader(form.Encode()))
	if err != nil {
		return Identity{}, fmt.Errorf("vkid: build exchange request: %w", err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Accept", "application/json")
	resp, err := e.httpClient.Do(req)
	if err != nil {
		return Identity{}, fmt.Errorf("vkid: exchange request: %w", err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
	if err != nil {
		return Identity{}, fmt.Errorf("vkid: read exchange response: %w", err)
	}
	if resp.StatusCode != http.StatusOK {
		// VK answers 400 with {error, error_description} on a bad/expired code or a
		// verifier/redirect mismatch — an authorization fault, not a transport error.
		return Identity{}, ErrInvalid
	}
	var out struct {
		UserID  numericID `json:"user_id"`
		IDToken string    `json:"id_token"`
		Error   string    `json:"error"`
	}
	if err := json.Unmarshal(body, &out); err != nil {
		return Identity{}, ErrInvalid
	}
	if out.Error != "" {
		return Identity{}, ErrInvalid
	}
	uid := string(out.UserID)
	if uid == "" || uid == "0" {
		uid = subjectFromIDToken(out.IDToken)
	}
	if uid == "" {
		return Identity{}, ErrInvalid
	}
	return Identity{ExternalID: uid}, nil
}

// subjectFromIDToken extracts the OIDC subject (the vk user id) from the payload of a
// VK ID id_token. The token arrives inside a direct TLS response from VK, so its
// signature is not re-verified here; the claim is only a fallback for a response that
// omits an explicit user_id.
func subjectFromIDToken(idToken string) string {
	parts := strings.Split(idToken, ".")
	if len(parts) != 3 {
		return ""
	}
	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil {
		return ""
	}
	var claims struct {
		Sub numericID `json:"sub"`
	}
	if err := json.Unmarshal(payload, &claims); err != nil {
		return ""
	}
	if s := string(claims.Sub); s != "0" {
		return s
	}
	return ""
}
