package oidc

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"slices"
	"strings"

	"github.com/ory/fosite"

	"github.com/pocket-id/pocket-id/backend/internal/model"
	datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
	"github.com/pocket-id/pocket-id/backend/internal/utils"
)

type cimdResolverConfig struct {
	getURLAllowlist    func() []string
	transport          http.RoundTripper
	transportDecorator func(http.RoundTripper) http.RoundTripper
}

type cimdClientResolver struct {
	resolver *fosite.CIMDResolver
	store    *Store
	policy   cimdPolicy
}

var _ fosite.ClientResolver = (*cimdClientResolver)(nil)
var _ fosite.CIMDClientPolicy = cimdPolicy{}

func newCIMDClientResolver(store *Store, config cimdResolverConfig) *cimdClientResolver {
	options := []fosite.CIMDFetcherOption{
		fosite.WithCIMDUserAgent("pocket-id/oidc-client-metadata-fetcher"),
		fosite.WithCIMDExtraPrivateRanges(utils.LocalIPv6IPNets()),
	}
	if config.transport != nil {
		options = append(options, fosite.WithCIMDTransport(config.transport))
	}
	if config.transportDecorator != nil {
		options = append(options, fosite.WithCIMDTransportDecorator(config.transportDecorator))
	}

	policy := cimdPolicy{getURLAllowlist: config.getURLAllowlist}
	return &cimdClientResolver{
		resolver: &fosite.CIMDResolver{
			Fetcher:                  fosite.NewDefaultCIMDFetcher(options...),
			Cache:                    store,
			Materializer:             store,
			Policy:                   policy,
			MaxConcurrentDiscoveries: 10,
		},
		store:  store,
		policy: policy,
	}
}

func (r *cimdClientResolver) ResolveClient(ctx context.Context, clientID string, next fosite.ClientLookupFunc) (fosite.Client, error) {
	if next == nil {
		return nil, errors.New("registered client resolver is required")
	}

	// Exclude persisted metadata clients so Fosite can apply its cache policy while still giving real registrations precedence
	registeredOnly := func(ctx context.Context, clientID string) (fosite.Client, error) {
		client, err := next(ctx, clientID)
		if err != nil {
			return nil, err
		}
		if pocketIDClient, ok := client.(Client); ok && pocketIDClient.IsMetadataDocument() {
			return nil, fosite.ErrNotFound
		}
		return client, nil
	}
	return r.resolver.ResolveClient(ctx, clientID, registeredOnly)
}

// RefreshMetadataClient forces a re-fetch of the metadata document for an already-cached CIMD client, bypassing the cache TTL
func (r *cimdClientResolver) RefreshMetadataClient(ctx context.Context, id string) (model.OidcClient, error) {
	if !fosite.LooksLikeCIMDURL(id) {
		return model.OidcClient{}, errors.New("client is not a client ID metadata document client")
	}
	if err := r.policy.AllowCIMDClient(ctx, id); err != nil {
		return model.OidcClient{}, err
	}
	existing, err := r.store.firstClientByID(ctx, id)
	if err != nil {
		return model.OidcClient{}, err
	}
	if !existing.IsMetadataDocument() {
		return model.OidcClient{}, errors.New("client is not a client ID metadata document client")
	}
	client, err := r.resolver.RefreshClient(ctx, id)
	if err != nil {
		return model.OidcClient{}, err
	}
	pocketIDClient, ok := client.(Client)
	if !ok {
		return model.OidcClient{}, errors.New("metadata resolver returned an incompatible client")
	}
	return pocketIDClient.OidcClient, nil
}

type cimdPolicy struct {
	getURLAllowlist func() []string
}

func (p cimdPolicy) cimdURLAllowed(id string) bool {
	if p.getURLAllowlist == nil {
		return false
	}
	return utils.MatchesAnyURLPattern(p.getURLAllowlist(), id)
}

// AllowCIMDClient applies Pocket ID's operator-managed dynamic-client allowlist
func (p cimdPolicy) AllowCIMDClient(_ context.Context, id string) error {
	if !p.cimdURLAllowed(id) {
		return errors.New("client ID is not in the metadata document allowlist")
	}
	return nil
}

// ValidateCIMDClient restricts generic CIMD features to those supported by Pocket ID's client model
func (cimdPolicy) ValidateCIMDClient(_ context.Context, doc *fosite.ClientMetadataDocument) error {
	// Require public-client authentication because Pocket ID does not persist CIMD key material
	switch doc.TokenEndpointAuthMethod {
	case "none":
	default:
		return fmt.Errorf("client metadata documents only support token_endpoint_auth_method %q, got %q", "none", doc.TokenEndpointAuthMethod)
	}

	// Require a flow that can initiate authorization (ignoring the grant types Pocket ID does not implement)
	if !hasInitiatingGrantType(supportedMetadataGrantTypes(doc.GrantTypes)) {
		return errInitiatingGrantRequired
	}

	// Pocket ID only implements the code response type for metadata clients
	responseTypes := doc.ResponseTypes
	if len(responseTypes) == 0 {
		responseTypes = []string{"code"}
	}
	for _, responseType := range responseTypes {
		if responseType != "code" {
			return fmt.Errorf("client metadata document contains unsupported response_type %q", responseType)
		}
	}

	return nil
}

var errInitiatingGrantRequired = errors.New("client metadata document must enable authorization_code or device_code")

// supportedMetadataGrantTypes keeps only the grant types Pocket ID implements for metadata clients and drops the rest
func supportedMetadataGrantTypes(grantTypes []string) []string {
	if len(grantTypes) == 0 {
		// Per RFC 7591 section 2: an omitted grant_types defaults to authorization_code
		return []string{string(fosite.GrantTypeAuthorizationCode)}
	}

	supported := make([]string, 0, len(grantTypes))
	for _, grantType := range grantTypes {
		switch grantType {
		case string(fosite.GrantTypeAuthorizationCode), string(fosite.GrantTypeDeviceCode), string(fosite.GrantTypeRefreshToken):
			supported = append(supported, grantType)
		}
	}
	return supported
}

func hasInitiatingGrantType(grantTypes []string) bool {
	return slices.Contains(grantTypes, string(fosite.GrantTypeAuthorizationCode)) ||
		slices.Contains(grantTypes, string(fosite.GrantTypeDeviceCode))
}

// validateMetadataRedirectURIs rejects self-asserted redirect URIs Pocket ID must not accept
func validateMetadataRedirectURIs(field string, uris []string) error {
	for _, raw := range uris {
		if strings.Contains(raw, "*") {
			return fmt.Errorf("%s entry %q must not contain a wildcard", field, raw)
		}
		u, err := url.Parse(raw)
		if err != nil {
			return fmt.Errorf("%s entry %q is not a valid URL: %w", field, raw, err)
		}
		if !u.IsAbs() {
			return fmt.Errorf("%s entry %q must be an absolute URL", field, raw)
		}
		// Mirrors the scheme restriction every administrator-registered callback URL passes
		switch strings.ToLower(u.Scheme) {
		case "javascript", "data":
			return fmt.Errorf("%s entry %q uses a disallowed scheme", field, raw)
		}
	}
	return nil
}

// buildClientFromMetadata applies Pocket ID's persisted-client projection to validated generic metadata
func buildClientFromMetadata(doc *fosite.ClientMetadataDocument, rawURL string) (model.OidcClient, error) {
	if err := validateMetadataRedirectURIs("redirect_uris", doc.RedirectURIs); err != nil {
		return model.OidcClient{}, err
	}
	if err := validateMetadataRedirectURIs("post_logout_redirect_uris", doc.PostLogoutRedirectURIs); err != nil {
		return model.OidcClient{}, err
	}

	// Record the supported grant types the document declares, so the client is neither silently granted capabilities it never declared nor persisted with grants Pocket ID cannot honor
	grantTypes := supportedMetadataGrantTypes(doc.GrantTypes)
	if !hasInitiatingGrantType(grantTypes) {
		return model.OidcClient{}, errInitiatingGrantRequired
	}

	client := model.OidcClient{
		Base:                        model.Base{ID: rawURL},
		Name:                        doc.ClientName,
		CallbackURLs:                datatype.StringList(doc.RedirectURIs),
		LogoutCallbackURLs:          datatype.StringList(doc.PostLogoutRedirectURIs),
		ClientType:                  model.OidcClientTypeCIMD,
		MetadataGrantTypes:          datatype.StringList(grantTypes),
		AccessTokenDurationMinutes:  model.DefaultAccessTokenDurationMinutes,
		RefreshTokenDurationMinutes: model.DefaultRefreshTokenDurationMinutes,
	}

	switch doc.TokenEndpointAuthMethod {
	case "none":
		client.IsPublic = true
		client.PkceEnabled = true
	default:
		return model.OidcClient{}, fmt.Errorf("client metadata documents only support token_endpoint_auth_method %q, got %q", "none", doc.TokenEndpointAuthMethod)
	}

	if client.Name == "" {
		if u, err := url.Parse(rawURL); err == nil {
			client.Name = u.Host
		}
	}

	return client, nil
}

// MaterializeCIMDClient converts validated generic metadata into Pocket ID's runtime client
func (s *Store) MaterializeCIMDClient(_ context.Context, doc *fosite.ClientMetadataDocument) (fosite.Client, error) {
	client, err := buildClientFromMetadata(doc, doc.ClientID)
	if err != nil {
		return nil, err
	}
	return Client{OidcClient: client}, nil
}
