package mfilesclient

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"errors"
	"fmt"
	"math"
	"os"
	"path/filepath"
	"sync"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/credentials/insecure"

	"github.com/M-Files/mfiles-grpc-api/go/mfiles"
)

// Message size limits, the same as M-Files' own client. gRPC's default of 4 MiB for received
// messages is too small for large search results and file blocks, so accept responses of any
// size; send at most what the server accepts by default (48 MiB).
const (
	MaxReceiveMessageSize = math.MaxInt32
	MaxSendMessageSize    = 48 * 1024 * 1024
)

// Client is one gRPC connection to an M-Files server, logged in to at most one vault at a time.
// Create service clients on Conn, e.g. mfiles.NewIRPCSearchClient(client.Conn). It is safe for
// concurrent use.
type Client struct {
	Conn *grpc.ClientConn

	host string

	mu                          sync.Mutex
	session                     *mfiles.SessionData
	metadataStructureVersion    int32
	hasMetadataStructureVersion bool
	cookies                     map[string]string
}

// [START dial]

// Dial creates the channel to cfg.Address. It does not log in and does not contact the server
// yet: the first call does.
func Dial(cfg Config) (*Client, error) {
	transport, err := transportCredentials(cfg)
	if err != nil {
		return nil, err
	}
	client := &Client{host: cfg.host()}
	client.Conn, err = grpc.NewClient(cfg.Address,
		grpc.WithTransportCredentials(transport),
		grpc.WithDefaultCallOptions(
			grpc.MaxCallRecvMsgSize(MaxReceiveMessageSize),
			grpc.MaxCallSendMsgSize(MaxSendMessageSize)),
		// Every call carries the M-Files metadata; failed calls return *Error.
		grpc.WithChainUnaryInterceptor(client.unaryInterceptor),
		grpc.WithChainStreamInterceptor(client.streamInterceptor))
	if err != nil {
		return nil, err
	}
	return client, nil
}

// transportCredentials is TLS with the system's root certificates, plus cfg.CACertFile when set;
// or no TLS at all when cfg.TLS is false.
func transportCredentials(cfg Config) (credentials.TransportCredentials, error) {
	if !cfg.TLS {
		return insecure.NewCredentials(), nil
	}
	roots, err := x509.SystemCertPool()
	if err != nil {
		roots = x509.NewCertPool()
	}
	if cfg.CACertFile != "" {
		pem, err := os.ReadFile(cfg.CACertFile)
		if err != nil {
			return nil, err
		}
		if !roots.AppendCertsFromPEM(pem) {
			return nil, fmt.Errorf("%s: no PEM certificates found", cfg.CACertFile)
		}
	}
	return credentials.NewTLS(&tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}), nil
}

// [END dial]

// Connect dials cfg.Address and logs in to cfg.Vault with the credentials or token in cfg.
// Close the client when done: it logs out and closes the channel.
func Connect(ctx context.Context, cfg Config) (*Client, error) {
	client, err := Dial(cfg)
	if err != nil {
		return nil, err
	}
	if _, err := client.LogIn(ctx, cfg); err != nil {
		client.Conn.Close()
		return nil, err
	}
	return client, nil
}

// LogIn logs in to cfg.Vault with a token if cfg.Token is set, else with cfg.Username and
// cfg.Password (M-Files authentication).
func (c *Client) LogIn(ctx context.Context, cfg Config) (*mfiles.SessionData, error) {
	if cfg.Vault == "" {
		return nil, errors.New("no vault: set MFILES_VAULT to the vault GUID")
	}
	var auth *mfiles.AuthDataClient
	switch {
	case cfg.Token != "":
		auth = TokenAuth(cfg.Token, cfg.AuthPlugin, cfg.AuthScope)
	case cfg.Username != "":
		auth = PasswordAuth(cfg.Username, cfg.Password)
	default:
		return nil, errors.New("no credentials: set MFILES_USERNAME and MFILES_PASSWORD, " +
			"or MFILES_TOKEN, MFILES_AUTH_PLUGIN and MFILES_AUTH_SCOPE")
	}
	return c.LogInWith(ctx, cfg.Vault, auth, cfg.ClientName)
}

// [START login-password]

// PasswordAuth is the authentication data of an M-Files user name and password.
func PasswordAuth(username, password string) *mfiles.AuthDataClient {
	return &mfiles.AuthDataClient{
		Type: mfiles.AuthDataType_AUTH_DATA_TYPE_CREDENTIALS,
		Data: &mfiles.AuthDataClientUnion{Data: &mfiles.AuthDataClientUnion_Credentials{
			Credentials: &mfiles.Credentials{
				Type:     mfiles.CredentialsType_CREDENTIALS_TYPE_MFILES,
				Username: username,
				Password: password,
			},
		}},
	}
}

// [END login-password]

// [START login-token]

// TokenAuth is the authentication data of a token from the vault's identity provider (SSO):
// the token is the only named value, "Token", for the vault's authentication plugin.
func TokenAuth(token, plugin, scope string) *mfiles.AuthDataClient {
	return &mfiles.AuthDataClient{
		Type: mfiles.AuthDataType_AUTH_DATA_TYPE_PLUGIN,
		Data: &mfiles.AuthDataClientUnion{Data: &mfiles.AuthDataClientUnion_Plugin{
			Plugin: &mfiles.AuthDataClientUnion_PluginData{
				AuthenticationAttemptIdentifier: &mfiles.AuthenticationAttemptIdentifier{
					Data: make([]byte, 32),
				},
				PluginName:         plugin,
				ConfigurationScope: scope,
				DataFormat:         mfiles.PluginAuthDataFormat_PLUGIN_AUTH_DATA_FORMAT_UNENCRYPTED,
				AuthData:           []*mfiles.NamedValue{{Key: "Token", Value: Text(token)}},
			},
		}},
	}
}

// [END login-token]

// [START login]

// LogInWith logs in to a vault. From then on every call carries the session.
func (c *Client) LogInWith(ctx context.Context, vault string, auth *mfiles.AuthDataClient,
	clientName string) (*mfiles.SessionData, error) {
	if clientName == "" {
		clientName = filepath.Base(os.Args[0])
	}
	response, err := mfiles.NewIRPCLoginClient(c.Conn).LogIn(ctx, &mfiles.LogInRequest{
		EnvironmentData: &mfiles.EnvironmentData{ClientName: clientName, HostPlatform: "Go"},
		ClientData: &mfiles.ClientData{
			Type:     mfiles.ClientType_CLIENT_TYPE_SERVER_API,
			Language: "en",
			// A plugin (token) login uses the 32-byte form of the attempt identifier.
			Capabilities: &mfiles.ClientCapabilities{LongAuthenticationAttemptIdentifier: true},
		},
		LoginData:          &mfiles.LoginData{ServerHostname: c.host, VaultGuid: vault},
		AuthenticationData: auth,
	})
	if err != nil {
		return nil, err
	}
	session := response.GetSessionData()
	if len(session.GetSessionId()) == 0 {
		// A plugin that needs more than one round trip answers without a session.
		return nil, errors.New("LogIn returned no session: multi-step authentication is not supported")
	}
	// From now on the interceptor sends session_id in mfiles-session-id-bin on every call.
	c.mu.Lock()
	c.session = session
	c.mu.Unlock()
	return session, nil
}

// [END login]

// Session is the current session, or nil when not logged in.
func (c *Client) Session() *mfiles.SessionData {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.session
}

// [START logout]

// LogOut ends the session. The client can log in again afterwards.
func (c *Client) LogOut(ctx context.Context) error {
	if c.Session() == nil {
		return nil
	}
	_, err := mfiles.NewIRPCLoginClient(c.Conn).LogOut(ctx, &mfiles.LogOutRequest{})
	c.mu.Lock()
	c.session = nil
	c.mu.Unlock()
	return err
}

// Close logs out if logged in, then closes the channel. A failed logout is ignored: the server
// closes an abandoned session when its keep-alive interval passes.
func (c *Client) Close() error {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	_ = c.LogOut(ctx)
	return c.Conn.Close()
}

// [END logout]

// MetadataStructureVersion is the vault's metadata structure version from the most recent call that
// reported one (trailer mfiles-metadata-version-bin). It changes when an administrator changes
// object types, classes, property definitions, value lists or workflows; use it to decide when
// cached structure is stale.
func (c *Client) MetadataStructureVersion() (version int32, ok bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.metadataStructureVersion, c.hasMetadataStructureVersion
}

func (c *Client) setMetadataStructureVersion(version int32) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.metadataStructureVersion, c.hasMetadataStructureVersion = version, true
}

func (c *Client) sessionID() []byte {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.session.GetSessionId()
}
