package service

import (
	"context"
	"flag"
	"fmt"
	"io/ioutil"
	"net"
	"os"
	"path/filepath"
	"runtime"
	"runtime/debug"
	"strconv"

	middleware "github.com/grpc-ecosystem/go-grpc-middleware"
	ctxtags "github.com/grpc-ecosystem/go-grpc-middleware/tags"
	"github.com/keys-pub/keys"
	"github.com/keys-pub/keys-ext/auth/fido2"
	"github.com/keys-pub/keys-ext/http/client"
	"github.com/keys-pub/keys-ext/vault"
	"github.com/keys-pub/keys-ext/wormhole"
	"github.com/keys-pub/keys-ext/wormhole/sctp"
	wsclient "github.com/keys-pub/keys-ext/ws/client"
	"github.com/keys-pub/keys/http"
	"github.com/keys-pub/keys/saltpack"
	"github.com/keys-pub/keys/tsutil"
	"github.com/keys-pub/keys/user"
	"github.com/keys-pub/keys/user/services"
	"github.com/mercari/go-grpc-interceptor/panichandler"
	"github.com/pkg/errors"
	"github.com/sirupsen/logrus"

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

func setupLogging(logLevel LogLevel, logPath string) (*logrus.Logger, LogInterceptor) {
	return setupLogrus(logLevel, logPath)
}

func logFatal(err error) {
	fmt.Fprintf(os.Stderr, "%v\n", err)
	os.Exit(1)
}

type args struct {
	appName  *string
	logPath  *string
	version  *bool
	port     *int
	logLevel *string
}

// Run the service.
func Run(build Build) {
	args := args{}
	args.appName = flag.String("app", "Keys", "app name")
	args.logPath = flag.String("log-path", "", "log path")
	args.version = flag.Bool("version", false, "print version")
	args.port = flag.Int("port", 0, "port to listen")
	args.logLevel = flag.String("log-level", "", "log level")

	flag.Parse()

	if *args.version {
		fmt.Printf("%s\n", build)
		return
	}

	logLevel, ok := parseLogLevel(*args.logLevel)
	if !ok {
		logFatal(errors.Errorf("invalid log level"))
	}

	env, err := NewEnv(*args.appName)
	if err != nil {
		logFatal(errors.Wrapf(err, "failed to load config"))
	}

	if len(flag.Args()) > 0 {
		logFatal(errors.Errorf("Invalid arguments. Did you mean to run `keys`?"))
	}

	// Save env
	if err := env.savePortFlag(*args.port); err != nil {
		logFatal(err)
	}

	// TODO: Disable logging by default?
	// TODO: Include package name in logging

	lg, lgi := setupLogging(logLevel, *args.logPath)
	SetLogger(lg)
	client.SetLogger(newPackageLogger(lg, "keys-ext/http/client"))
	keys.SetLogger(newPackageLogger(lg, "keys"))
	saltpack.SetLogger(newPackageLogger(lg, "keys/saltpack"))
	sctp.SetLogger(newPackageLogger(lg, "keys-ext/wormhole/sctp"))
	user.SetLogger(newPackageLogger(lg, "keys/user"))
	services.SetLogger(newPackageLogger(lg, "keys/user/services"))
	vault.SetLogger(newPackageLogger(lg, "keys-ext/vault"))
	wormhole.SetLogger(newPackageLogger(lg, "keys-ext/wormhole"))
	wsclient.SetLogger(newPackageLogger(lg, "keys-ext/ws/client"))

	logger.Debugf("Running %v", os.Args)

	if err := checkSupportedOS(); err != nil {
		logFatal(err)
	}
	if runtime.GOOS == "darwin" {
		if err := checkCodesigned(); err != nil {
			logFatal(err)
		}
	}

	logger.Infof("Version: %s", build)
	logger.Infof("Log level: %s", logLevel.String())

	panichandler.InstallPanicHandler(func(ctx context.Context, r interface{}) {
		logrus.Errorf("Panic: %v; %s", r, string(debug.Stack()))
	})

	if err := runService(env, build, lgi); err != nil {
		logFatal(err)
	}
}

// ServeFn starts the service.
type ServeFn func() error

// CloseFn closes the service.
type CloseFn func()

// TODO: Protect against incompatible downgrades

func runService(env *Env, build Build, lgi LogInterceptor) error {
	if IsPortInUse(env.Port()) {
		return errors.Errorf("port %d in use; is keysd already running?", env.Port())
	}

	cert, err := GenerateCertificate(env, true)
	if err != nil {
		return err
	}
	defer func() { _ = DeleteCertificate(env) }()

	serveFn, closeFn, serveErr := NewServiceFn(env, build, cert, lgi)
	if serveErr != nil {
		return serveErr
	}
	defer closeFn()
	return serveFn()
}

// NewServiceFn ...
func NewServiceFn(env *Env, build Build, cert *keys.CertificateKey, lgi LogInterceptor) (ServeFn, CloseFn, error) {
	var opts []grpc.ServerOption

	if cert == nil {
		return nil, nil, errNoCertFound{}
	}
	tlsCert := cert.TLSCertificate()
	creds := credentials.NewServerTLSFromCert(&tlsCert)

	opts = []grpc.ServerOption{
		grpc.Creds(creds),
	}

	auth := newAuth(env)

	lgi.Replace()

	opts = append(opts, middleware.WithUnaryServerChain(
		ctxtags.UnaryServerInterceptor(ctxtags.WithFieldExtractor(ctxtags.CodeGenRequestFieldExtractor)),
		lgi.Unary(),
		auth.unaryInterceptor,
		panichandler.UnaryServerInterceptor,
	),
		middleware.WithStreamServerChain(
			ctxtags.StreamServerInterceptor(ctxtags.WithFieldExtractor(ctxtags.CodeGenRequestFieldExtractor)),
			lgi.Stream(),
			auth.streamInterceptor,
			panichandler.StreamServerInterceptor,
		),
	)
	grpcServer := grpc.NewServer(opts...)

	client := http.NewClient()
	service, err := newService(env, build, auth, client, tsutil.NewClock())
	if err != nil {
		return nil, nil, err
	}

	if err := service.Open(); err != nil {
		return nil, nil, err
	}

	// Keys service
	logger.Infof("Registering Keys service...")
	RegisterRPCServer(grpcServer, service)

	// FIDO2
	fido2Plugin, err := fido2.OpenPlugin(filepath.Join(exeDir(), "fido2.so"))
	if err != nil {
		logger.Errorf("fido2 plugin is not available: %v", err)
	} else {
		logger.Infof("Registering FIDO2 plugin...")
		fido2.RegisterFIDO2Server(grpcServer, fido2Plugin)
		auth.fas = fido2Plugin
	}

	logger.Infof("Listening for connections on port %d", env.Port())
	lis, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", env.Port()))
	if err != nil {
		return nil, nil, errors.Wrapf(err, "failed to tcp listen")
	}

	serveFn := func() error {
		if err := writePID(env); err != nil {
			return err
		}
		return grpcServer.Serve(lis)
	}
	closeFn := func() {
		grpcServer.Stop()
		service.Close()
	}
	return serveFn, closeFn, nil
}

// IsPortInUse returns true if port is currently in use.
func IsPortInUse(port int) bool {
	lis, lisErr := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
	if lisErr != nil {
		return true
	}
	_ = lis.Close()
	return false
}

func writePID(env *Env) error {
	path, err := env.AppPath("pid", false)
	if err != nil {
		return err
	}
	pid := os.Getpid()
	return ioutil.WriteFile(path, []byte(strconv.Itoa(pid)), filePerms)
}
