package gtslib_auth_keyring

import (
	"bufio"
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	neturl "net/url"
	"os"
	"path/filepath"
	"strings"

	"github.com/adrg/xdg"
	"github.com/go-openapi/runtime"
	httptransport "github.com/go-openapi/runtime/client"
	"github.com/go-openapi/strfmt"
	"github.com/pkg/browser"
	"github.com/pkg/errors"
	"github.com/zalando/go-keyring"
	"golang.org/x/time/rate"
	"webfinger.net/go/webfinger"

	apiclient "git.coopcloud.tech/decentral1se/gtslib/client"
	"git.coopcloud.tech/decentral1se/gtslib/client/apps"
	"git.coopcloud.tech/decentral1se/gtslib/models"
)

func Ptr[T any](v T) *T { return &v }

// Config is a client configuration.
type Config struct {
	Name    string // client name
	Website string // client website
}

// Option with operates on a client config.
type Option func(c *Config)

// NewConfig creates a new client config.
func NewConfig(opts ...Option) *Config {
	conf := &Config{
		Name:    "gtslib-auth-keyring",
		Website: "https://doesnt.exist/gtslib-auth-keyring",
	}
	for _, optFunc := range opts {
		optFunc(conf)
	}
	return conf
}

// WithName sets a client name.
func WithName(name string) Option {
	return func(c *Config) {
		c.Name = name
	}
}

// WithWebsite sets a client website.
func WithWebsite(website string) Option {
	return func(c *Config) {
		c.Website = website
	}
}

// Prefs stores persisted preferences.
type Prefs struct {
	// Instances is a map of instance names to instance preferences.
	Instances map[string]PrefsInstance `json:"instances,omitempty"`

	// Users is a map of user usernames@domains to instance data.
	Users map[string]PrefsUser `json:"users,omitempty"`

	// DefaultUser is the username@domain of the last user we successfully
	// authenticated as, if there is one.
	DefaultUser string `json:"default_user,omitempty"`
}

// PrefsInstance stores preferences for a given instance. OAuth2 app secrets
// are stored in the system keychain.
type PrefsInstance struct {
	// ClientID is the OAuth2 client ID on this instance.
	ClientID string `json:"client_id"`
}

// PrefsUser stores preferences for a given user. User access tokens are stored
// in the system keychain.
type PrefsUser struct {
	// Instance is the name of the instance that the user is on.
	Instance string `json:"instance"`
}

// prefsDir is the path to the directory containing all preference files.
var prefsDir string

// prefsPath is the path to the file within that directory that stores all of our prefs.
var prefsPath string

func init() {
	prefsDir = filepath.Join(xdg.ConfigHome, "gtslib.auth.keyring")
	prefsPath = filepath.Join(prefsDir, "prefs.json")
}

// LoadPrefs returns preferences from disk or an empty prefs object if there
// are none stored or accessible.
func LoadPrefs() (*Prefs, error) {
	f, err := os.Open(prefsPath)
	if err != nil {
		return &Prefs{
			Instances: map[string]PrefsInstance{},
			Users:     map[string]PrefsUser{},
		}, nil
	}
	defer func() { _ = f.Close() }()

	var prefs Prefs
	err = json.NewDecoder(f).Decode(&prefs)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	if prefs.Instances == nil {
		prefs.Instances = map[string]PrefsInstance{}
	}
	if prefs.Users == nil {
		prefs.Users = map[string]PrefsUser{}
	}

	return &prefs, nil
}

// SavePrefs creates on-disk preferences or overwrites existing ones.
func SavePrefs(prefs *Prefs) error {
	err := os.MkdirAll(prefsDir, 0o755)
	if err != nil {
		return errors.WithStack(err)
	}

	f, err := os.Create(prefsPath)
	if err != nil {
		return errors.WithStack(err)
	}
	defer func() { _ = f.Close() }()

	encoder := json.NewEncoder(f)
	encoder.SetIndent("", "  ")
	encoder.SetEscapeHTML(false)
	err = encoder.Encode(prefs)
	if err != nil {
		return errors.WithStack(err)
	}

	return nil
}

// PrefNotFound represents an error in which no preference is found.
var PrefNotFound = errors.New("preference value not found")

// getPrefValue retrieves a preference value.
func getPrefValue(get func(prefs *Prefs) (string, bool)) (string, error) {
	prefs, err := LoadPrefs()
	if err != nil {
		return "", err
	}

	value, exists := get(prefs)
	if !exists {
		return "", errors.WithStack(PrefNotFound)
	}

	return value, nil
}

// setPrefValue sets a preference value.
func setPrefValue(set func(prefs *Prefs)) error {
	prefs, err := LoadPrefs()
	if err != nil {
		return err
	}

	set(prefs)

	err = SavePrefs(prefs)
	if err != nil {
		return err
	}

	return nil
}

// GetDefaultUser retrieves the default user.
func GetDefaultUser() (string, error) {
	return getPrefValue(func(prefs *Prefs) (string, bool) {
		if prefs.DefaultUser == "" {
			return "", false
		}
		return prefs.DefaultUser, true
	})
}

// SetDefaultUser sets the default user.
func SetDefaultUser(user string) error {
	return setPrefValue(func(prefs *Prefs) {
		prefs.DefaultUser = user
	})
}

// GetInstanceClientID retrieves the instance client ID.
func GetInstanceClientID(instance string) (string, error) {
	return getPrefValue(func(prefs *Prefs) (string, bool) {
		prefsInstance, exists := prefs.Instances[instance]
		if !exists {
			return "", false
		}
		return prefsInstance.ClientID, true
	})
}

// SetInstanceClientID sets the instance client ID.
func SetInstanceClientID(instance string, clientID string) error {
	return setPrefValue(func(prefs *Prefs) {
		prefsInstance := prefs.Instances[instance]
		prefsInstance.ClientID = clientID
		prefs.Instances[instance] = prefsInstance
	})
}

// GetUserInstance gets the user instance.
func GetUserInstance(user string) (string, error) {
	return getPrefValue(func(prefs *Prefs) (string, bool) {
		prefsUser, exists := prefs.Users[user]
		if !exists {
			return "", false
		}
		return prefsUser.Instance, true
	})
}

// SetUserInstance sets the user instance.
func SetUserInstance(user string, instance string) error {
	return setPrefValue(func(prefs *Prefs) {
		prefsUser := prefs.Users[user]
		prefsUser.Instance = instance
		prefs.Users[user] = prefsUser
	})
}

// Client is a GtS API client with attached authentication credentials and rate
// limiter. Credentials may be no-op.
type Client struct {
	Client  *apiclient.GoToSocialSwaggerDocumentation
	Auth    runtime.ClientAuthInfoWriter
	limiter *rate.Limiter
	ctx     context.Context
}

func (c *Client) Wait() error {
	if err := c.limiter.Wait(c.ctx); err != nil {
		return errors.WithStack(err)
	}

	return nil
}

// NewAuthClient creates a new authentication client.
func NewAuthClient(user string) (*Client, error) {
	var err error

	if user == "" {
		user, err = GetDefaultUser()
		if err != nil {
			slog.Error("no user provided, couldn't get default user from prefs (did you log in first?)")
			return nil, err
		}
	}

	instance, err := GetUserInstance(user)
	if err != nil {
		slog.Error("couldn't get user's instance from prefs (did you log in first?)", "user", user)
		return nil, err
	}

	accessToken, err := keyring.Get(keyringServiceAccessToken, user)
	if err != nil {
		slog.Error("couldn't find user's access token (did you log in first?)", "user", user)
		return nil, err
	}

	return &Client{
		Client:  clientForInstance(instance),
		Auth:    httptransport.BearerToken(accessToken),
		limiter: rate.NewLimiter(1.0, 300),
		ctx:     context.Background(),
	}, nil
}

const (
	keyringServiceAccessToken  = "gtslib.auth.keyring.access-token"
	keyringServiceClientSecret = "gtslib.auth.keyring.client-secret"
)

const (
	oauthRedirect = "urn:ietf:wg:oauth:2.0:oob"
	oauthScopes   = "read write"
)

// Login authenticates the user and saves the credentials in the system
// keychain.
func Login(user string, opts ...Option) error {
	var err error

	if user == "" {
		user, err = GetDefaultUser()
		if err != nil {
			slog.Error("no user provided, couldn't get default user from prefs (have you logged in before?)")
			return err
		}
	}

	if user == "" {
		return errors.WithStack(errors.New("a user is required"))
	}
	if !strings.ContainsRune(user, '@') {
		return errors.WithStack(errors.New("a fully qualified user with a domain is required"))
	}
	if user[0] == '@' {
		return errors.WithStack(errors.New("take the leading @ off the user and try again"))
	}

	if _, err := keyring.Get(keyringServiceAccessToken, user); err == nil {
		slog.Warn("already logged in, will log in again", "user", user)
	}

	instance, err := ensureInstance(user)
	if err != nil {
		slog.Error("couldn't get user's instance", "user", user, "error", err)
		return err
	}

	conf := NewConfig(opts...)
	client := clientForInstance(instance)
	clientID, clientSecret, err := ensureAppCredentials(instance, client, conf)
	if err != nil {
		slog.Error("OAuth2 app setup failed", "user", user, "instance", instance, "error", err)
		return err
	}

	code := promptForOAuthCode(instance, clientID)

	accessToken, err := exchangeCodeForToken(instance, clientID, clientSecret, code)
	if err != nil {
		slog.Error("couldn't exchange OAuth2 authorization code for access token", "user", user, "instance", instance, "error", err)
		return err
	}

	err = keyring.Set(keyringServiceAccessToken, user, accessToken)
	if err != nil {
		slog.Error("couldn't set access token in keychain", "user", user, "instance", instance, "error", err)
		return err
	}

	err = SetDefaultUser(user)
	if err != nil {
		slog.Error("couldn't set default user in prefs", "user", user, "instance", instance, "error", err)
		return err
	}

	slog.Info("login successful", "user", user, "instance", instance)

	return nil
}

// ensureInstance finds a user's instance or retrieves a previously cached
// instance for them.
func ensureInstance(user string) (string, error) {
	if instance, err := GetUserInstance(user); err == nil {
		return instance, nil
	}

	instance, err := findInstance(user)
	if err != nil {
		slog.Error("WebFinger lookup failed", "user", user, "error", err)
		return "", err
	}

	err = SetUserInstance(user, instance)
	if err != nil {
		slog.Error("couldn't set instance in prefs", "user", user, "instance", instance, "error", err)
		return "", err
	}

	return instance, nil
}

// findInstance does a WebFinger lookup to find the domain of the instance API
// for a given user.
func findInstance(user string) (string, error) {
	webfingerClient := webfinger.NewClient(nil)
	jrd, err := webfingerClient.Lookup(user, nil)
	if err != nil {
		return "", err
	}

	var href string
	for _, link := range jrd.Links {
		if link.Rel == "self" && link.Type == "application/activity+json" {
			href = link.Href
			break
		}
	}
	if href == "" {
		return "", errors.New("no link with rel=\"self\" and type=\"application/activity+json\"")
	}

	url, err := neturl.Parse(href)
	if err != nil {
		return "", err
	}

	if url.Scheme != "https" || !(url.Port() == "" || url.Port() == "443") || url.Hostname() == "" {
		return "", errors.New("unexpected URL format")
	}

	return url.Hostname(), nil
}

// clientForInstance retrieves the client for a specific instance.
func clientForInstance(instance string) *apiclient.GoToSocialSwaggerDocumentation {
	return apiclient.New(httptransport.New(instance, "", []string{"https"}), strfmt.Default)
}

// ensureAppCredentials retrieves or creates and stores app credentials.
func ensureAppCredentials(
	instance string,
	client *apiclient.GoToSocialSwaggerDocumentation,
	conf *Config) (string, string, error) {
	shouldCreateNewApp := false

	clientID, err := GetInstanceClientID(instance)
	if clientID == "" || errors.Is(err, keyring.ErrNotFound) {
		shouldCreateNewApp = true
	} else if err != nil {
		slog.Error("couldn't get client ID from prefs", "instance", instance, "error", err)
		return "", "", err
	}

	clientSecret, err := keyring.Get(keyringServiceClientSecret, instance)
	if clientSecret == "" || errors.Is(err, keyring.ErrNotFound) {
		shouldCreateNewApp = true
	} else if err != nil {
		slog.Error("couldn't get client secret from keychain", "instance", instance, "error", err)
		return "", "", err
	}

	if !shouldCreateNewApp {
		return clientID, clientSecret, nil
	}

	app, err := createApp(client, conf)
	if err != nil {
		slog.Error("couldn't create OAuth2 app", "instance", instance, "error", err)
		return "", "", err
	}
	clientID = app.ClientID
	clientSecret = app.ClientSecret

	err = SetInstanceClientID(instance, clientID)
	if err != nil {
		slog.Error("couldn't set client ID in prefs", "instance", instance, "error", err)
		return "", "", err
	}
	err = keyring.Set(keyringServiceClientSecret, instance, clientSecret)
	if err != nil {
		slog.Error("couldn't set client secret in keychain", "instance", instance, "error", err)
		return "", "", err
	}

	return clientID, clientSecret, nil
}

// createApp registers a new OAuth2 application.
func createApp(
	client *apiclient.GoToSocialSwaggerDocumentation,
	conf *Config) (*models.Application, error) {
	resp, err := client.Apps.AppCreate(
		&apps.AppCreateParams{
			ClientName:   conf.Name,
			RedirectURIs: oauthRedirect,
			Scopes:       Ptr(oauthScopes),
			Website:      Ptr(conf.Website),
		},
		func(op *runtime.ClientOperation) {
			op.ConsumesMediaTypes = []string{"application/x-www-form-urlencoded"}
		},
	)
	if err != nil {
		return nil, err
	}
	return resp.GetPayload(), nil
}

// promptForOAuthCode prompts for an OAuth code.
func promptForOAuthCode(instance string, clientID string) string {
	oauthAuthorizeURL := (&neturl.URL{
		Scheme: "https",
		Host:   instance,
		Path:   "/oauth/authorize",
		RawQuery: neturl.Values{
			"response_type": []string{"code"},
			"client_id":     []string{clientID},
			"redirect_uri":  []string{oauthRedirect},
			"scope":         []string{oauthScopes},
		}.Encode(),
	}).String()
	err := browser.OpenURL(oauthAuthorizeURL)
	if err != nil {
		slog.Warn("couldn't open browser to authorize", "error", err)
		print("Please open this URL in your browser:", oauthAuthorizeURL)
	}

	print("Enter authorization code: ")
	scanner := bufio.NewScanner(os.Stdin)
	scanner.Scan()
	code := strings.TrimSpace(scanner.Text())

	return code
}

type oauthTokenOK struct {
	AccessToken string `json:"access_token"`
	TokenType   string `json:"token_type"`
	Scope       string `json:"scope"`
	CreatedAt   int64  `json:"created_at"`
}

type oauthTokenError struct {
	Error            string `json:"error"`
	ErrorDescription string `json:"error_description"`
}

// exchangeCodeForToken exchanges an authorization code for an access token.
func exchangeCodeForToken(instance string, clientID string, clientSecret string, code string) (string, error) {
	oauthTokenURL := (&neturl.URL{
		Scheme: "https",
		Host:   instance,
		Path:   "/oauth/token",
	}).String()

	// TODO: add this to GtS Swagger doc
	resp, err := http.Post(oauthTokenURL, "application/x-www-form-urlencoded", strings.NewReader(neturl.Values{
		"grant_type":    []string{"authorization_code"},
		"code":          []string{code},
		"client_id":     []string{clientID},
		"client_secret": []string{clientSecret},
		"redirect_uri":  []string{oauthRedirect},
		"scope":         []string{oauthScopes},
	}.Encode()))
	if err != nil {
		slog.Error("call to OAuth2 token endpoint failed", "instance", instance, "error", err)
		return "", err
	}

	if resp.StatusCode != http.StatusOK {
		var payload oauthTokenError
		err = json.NewDecoder(resp.Body).Decode(&payload)
		if err != nil {
			slog.Error("couldn't decode OAuth2 token endpoint error response", "instance", instance, "error", err)
			return "", err
		}
		return "", errors.WithStack(errors.New(payload.ErrorDescription))
	}

	var payload oauthTokenOK
	err = json.NewDecoder(resp.Body).Decode(&payload)
	if err != nil {
		slog.Error("couldn't decode OAuth2 token endpoint success response", "instance", instance, "error", err)
		return "", err
	}

	if payload.TokenType != "Bearer" {
		err = errors.WithStack(errors.New("unknown access token type"))
		slog.Error("unexpected response from OAuth2 token endpoint", "instance", instance, "token_type", payload.TokenType)
		return "", err
	}

	if payload.Scope != oauthScopes {
		err = errors.WithStack(errors.New("scopes are not what we asked for"))
		slog.Error("unexpected response from OAuth2 token endpoint", "instance", instance, "scopes", payload.Scope)
		return "", err
	}

	return payload.AccessToken, nil
}
