package main

import (
	"context"
	"flag"
	"fmt"
	golog "log"
	"os"
	"os/signal"
	"path/filepath"
	"strings"
	"sync"
	"syscall"
	"time"

	_ "embed"

	ginzap "github.com/gin-contrib/zap"
	"github.com/gin-gonic/gin"
	"github.com/go-errors/errors"
	"github.com/oapi-codegen/nullable"
	"github.com/phishingclub/phishingclub/acme"
	"github.com/phishingclub/phishingclub/app"
	"github.com/phishingclub/phishingclub/build"
	"github.com/phishingclub/phishingclub/cli"
	"github.com/phishingclub/phishingclub/data"
	"github.com/phishingclub/phishingclub/database"
	"github.com/phishingclub/phishingclub/errs"
	"github.com/phishingclub/phishingclub/install"
	"github.com/phishingclub/phishingclub/middleware"
	"github.com/phishingclub/phishingclub/model"
	"github.com/phishingclub/phishingclub/repository"
	"github.com/phishingclub/phishingclub/seed"
	"github.com/phishingclub/phishingclub/sso"
	"github.com/phishingclub/phishingclub/task"
	"github.com/phishingclub/phishingclub/version"
	"github.com/phishingclub/phishingclub/vo"
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
	"gorm.io/gorm"
	gormLogger "gorm.io/gorm/logger"
)

const (
	APP_NAME = "Phishing Club"
)

var (
	flagInstall                 = flag.Bool("install", false, "Install as a systemd service (interactive)")
	flagInstallNonInteractive   = flag.Bool("install-non-interactive", false, "Install as a systemd service without interactive prompts")
	flagUpdate                  = flag.Bool("update", false, "Update the application binary and restart the service")
	flagUninstall               = flag.Bool("uninstall", false, "Uninstall the application with confirmation prompt")
	flagUninstallNonInteractive = flag.Bool("uninstall-non-interactive", false, "Uninstall the application without confirmation prompt")
	flagSystemd                 = flag.Bool("systemd", false, "Indicates the application is running as a systemd service, this flag is only functional on the initial boot when seeding the database.")
	flagVersion                 = flag.Bool("version", false, "Show version")
	flagConfigPath              = flag.String("config", "./config.json", "Path to config file")
	flagFilePath                = flag.String("files", "./data", "Path to save application data")
	env                         = flag.Bool("env", false, "Outputs the available environment variables")
	flagRecovery                = flag.Bool("recover", false, "Used for interactive recovery of an account")
	flagConfigOnly              = flag.Bool("config-only", false, "Run interactive installer and save config without installing")
	flagDebug                   = flag.Bool("debug", false, "Force debug logging on db and app logger, ignores db settings on startup")
	flagMigrate                 = flag.String("migrate", "", "Run a named one-off data migration then exit (available: lowercase-recipient-emails)")
	flagDryRun                  = flag.Bool("dry-run", false, "With --migrate, report what would change without writing any changes")
)

func main() {
	flag.Parse()

	if *env {
		cli.OutputEnv()
		return
	}

	if *flagVersion {
		cli.PrintVersion(APP_NAME, version.Get())
		return
	}

	if *flagConfigOnly {
		if err := install.RunInteractiveConfigOnly(*flagConfigPath); err != nil {
			golog.Fatalf("Config generation failed: %s", err)
		}
		return
	}

	if *flagInstall {
		if err := install.Install(); err != nil {
			golog.Fatalf("Installation failed: %s", err)
		}
		return
	}

	if *flagInstallNonInteractive {
		if err := install.InstallNonInteractive(); err != nil {
			golog.Fatalf("Installation failed: %s", err)
		}
		return
	}

	if *flagUninstall {
		if err := install.Uninstall(); err != nil {
			golog.Fatalf("Uninstallation failed: %s", err)
		}
		return
	}

	if *flagUninstallNonInteractive {
		if err := install.UninstallNonInteractive(); err != nil {
			golog.Fatalf("Uninstallation failed: %s", err)
		}
		return
	}

	if *flagUpdate {
		if err := install.Update(); err != nil {
			golog.Fatalf("Update failed: %s", err)
		}
		return
	}

	buildMode := app.MODE_DEVELOPMENT
	if build.Flags.Production {
		buildMode = app.MODE_PRODUCTION
	}

	// check if the files path ends with / else add it
	if (*flagFilePath)[len(*flagFilePath)-1:] != "/" {
		*flagFilePath = *flagFilePath + "/"
	}
	acmeCertPath := fmt.Sprintf("%scerts", *flagFilePath)
	ownManagedTLSPath := fmt.Sprintf("%scerts/own-managed", *flagFilePath)
	assetPath := fmt.Sprintf("%sassets", *flagFilePath)
	attachmentsPath := fmt.Sprintf("%sattachments", *flagFilePath)

	// print banner and version
	cli.PrintBanner()
	cli.PrintVersion(APP_NAME, version.Get())
	// get conf
	conf, err := app.SetupConfig(
		buildMode,
		*flagConfigPath,
	)
	if err != nil {
		golog.Fatalf("failed to setup config: %s", err)
	}
	// setup database connection
	db, err := app.SetupDatabase(conf)
	if err != nil {
		golog.Fatalf("Failed to connect to database: %s\nDSN: %s", err, conf.Database().DSN)
	}
	logger, atomicLogger, err := app.SetupLogger(buildMode, conf)
	if err != nil {
		golog.Fatalf("failed to setup logger: %s", err)
	}
	defer func() {
		_ = logger.Sync()
	}()
	// set log levels
	err = setLogLevels(db, atomicLogger, *flagDebug)
	if err != nil {
		// this could fail due to db not being seeded
		db.Logger = db.Logger.LogMode(gormLogger.Silent)
		atomicLogger.SetLevel(zap.InfoLevel)
	}
	// output debug information
	/*
		wd, err := os.Getwd()
		if err != nil {
			logger.Fatalw("Failed to get working directory", "error", err)
		}
		usr, err := user.Current()
		if err != nil {
			logger.Fatalw("Failed to get current user", "error", err)
		}
		// setup configuration
		logger.Debugw("debug",
			"applicationMode", buildMode,
			"working directory", wd,
			"OS user", usr.Username,
			"pathConfig", *flagConfigPath,
		)
	*/
	if p := conf.LogPath; len(p) > 0 {
		logger.Debugw("using log file", "path", p)
	}
	if p := conf.ErrLogPath; len(p) > 0 {
		logger.Debugw("using error log file", "path", p)
	}
	// gin is always set to production mode
	if buildMode == app.MODE_DEVELOPMENT {
		gin.SetMode(gin.ReleaseMode)
	} else {
		gin.SetMode(gin.ReleaseMode)
	}

	// setup utils and repos
	utils := app.NewUtils()
	repositories := app.NewRepositories(db)
	// run migrations and seeding, including development seeding
	// Use systemd flag to indicate this was installed via systemd
	usingSystemd := *flagInstall || *flagSystemd
	err = seed.InitialInstallAndSeed(db, repositories, logger, usingSystemd)
	if err != nil {
		logger.Fatalw("Failed to run migrations and seeding", "error", err)
	}
	// run a one-off opt in data migration and exit, the schema is guaranteed
	// by the seeding above
	if *flagMigrate != "" {
		switch *flagMigrate {
		case "lowercase-recipient-emails":
			if _, err := seed.LowercaseRecipientEmails(db, logger, *flagDryRun); err != nil {
				logger.Fatalw("migration failed", "migration", *flagMigrate, "error", err)
			}
		default:
			logger.Fatalf("unknown migration: %s", *flagMigrate)
		}
		return
	}
	// setup logging again so it is according to the database
	err = setLogLevels(db, atomicLogger, *flagDebug)
	if err != nil {
		// this could fail due to db not being seeded
		db.Logger = db.Logger.LogMode(gormLogger.Silent)
		atomicLogger.SetLevel(zap.InfoLevel)
	}
	// setup cert magic for TLS cert handling
	certMagicConfig, certMagicCache, err := acme.SetupCertMagic(
		acmeCertPath+"/acme",
		conf,
		db,
		logger,
	)
	if err != nil {
		logger.Errorw("failed to setup certmagic", "error", err)
		return
	}
	// setup services, middleware and controllers
	services := app.NewServices(
		db,
		repositories,
		logger,
		utils,
		assetPath,
		attachmentsPath,
		ownManagedTLSPath,
		buildMode,
		certMagicConfig,
		certMagicCache,
		*flagFilePath,
		conf.IPSecurity.TrustedProxies,
		conf.RemoteBrowser.ExecPath,
	)
	// get entra-id options and setup msal client
	ssoOpt, err := services.SSO.GetSSOOptionWithoutAuth(context.Background())
	if err != nil {
		logger.Errorw("failed to setup sso", "error", err)
		return
	}
	if ssoOpt.Enabled {
		switch ssoOpt.Provider() {
		case data.SSOProviderOIDC:
			services.SSO.OIDCClient, err = sso.NewOIDCClient(context.Background(), ssoOpt)
			if err != nil && !errors.Is(err, errs.ErrSSODisabled) {
				// discovery can fail if the provider is unreachable at startup,
				// log and continue so the rest of the server still comes up
				logger.Errorw("failed to setup OIDC client", "error", err)
			}
			// the failure is recoverable, do not let it leak into later checks
			err = nil
		default:
			services.SSO.MSALClient, err = sso.NewEntreIDClient(ssoOpt)
			if err != nil && !errors.Is(err, errs.ErrSSODisabled) {
				logger.Errorw("failed to setup msal client", "error", err)
				return
			}
		}
	}
	middlewares := app.NewMiddlewares(
		1,
		1,
		conf,
		services,
		utils,
		logger,
	)
	controllers := app.NewControllers(
		assetPath,
		attachmentsPath,
		repositories,
		services,
		logger,
		atomicLogger,
		utils,
		db,
		conf,
	)
	// setup admin account
	isInstalled, err := controllers.InitialSetup.IsInstalled(context.Background())
	if err != nil {
		logger.Fatalw("failed to check if app is installed", "error", err)
	}
	if !isInstalled {
		err := controllers.InitialSetup.HandleInitialSetup(context.Background())
		if err != nil {
			logger.Fatalw("failed to handle the installers initial setup", "error", err)
		}
	}
	// TODO run migrations for existing databases

	// interactive account recovery
	if *flagRecovery {
		interactiveAccountRecovery(repositories, utils)
		return
	}
	// setup administration server
	var adminRouter *gin.Engine
	if !build.Flags.Production {
		adminRouter = gin.Default()
	} else {
		adminRouter = gin.New()
		adminRouter.Use(ginzap.GinzapWithConfig(logger.Desugar(), &ginzap.Config{
			TimeFormat: time.RFC3339,
			UTC:        true,
			Context: ginzap.Fn(func(c *gin.Context) []zapcore.Field {
				fields := []zapcore.Field{}
				fields = append(fields, zap.String("host", c.Request.Host))
				fields = append(fields, zap.String("server", "admin"))

				return fields
			}),
		}))
		adminRouter.Use(ginzap.RecoveryWithZap(logger.Desugar(), true))
		// dont trust x-forwarded-by by default
		if len(conf.IPSecurity.TrustedProxies) > 0 {
			err := adminRouter.SetTrustedProxies(conf.IPSecurity.TrustedProxies)
			if err != nil {
				logger.Fatalw("failed to set trusted proxies", "error", err)
			}
		} else {
			err := adminRouter.SetTrustedProxies(nil)
			if err != nil {
				logger.Fatalw("failed to set trusted proxies", "error", err)
			}
		}
		// trust specific headers
		adminRouter.TrustedPlatform = conf.IPSecurity.TrustedIPHeader
		logger.Debugw("admin IP security",
			"admin_allowed", strings.Join(conf.IPSecurity.AdminAllowed, ","),
			"trusted_proxies", strings.Join(conf.IPSecurity.TrustedProxies, ","),
			"trusted_ip_header", conf.IPSecurity.TrustedIPHeader,
		)
	}
	adminRouter.Use(middlewares.IPLimiter)
	adminRouter.Use(middleware.SecurityHeaders())

	// read the seeded victim WS path for the remote browser endpoint
	rbWSPath := "rbws" // fallback - real value is seeded at first startup
	if opt, err := repositories.Option.GetByKey(context.Background(), data.OptionKeyRemoteBrowserWSPath); err == nil {
		rbWSPath = opt.Value.String()
	}

	// read the seeded path for the recipient report endpoint
	reportPath := "report" // fallback - real value is seeded at first startup
	if opt, err := repositories.Option.GetByKey(context.Background(), data.OptionKeyReportPath); err == nil {
		reportPath = opt.Value.String()
	}

	adminServer := app.NewAdministrationServer(
		adminRouter,
		controllers,
		middlewares,
		logger,
		certMagicConfig,
		build.Flags.Production,
	)
	adminStartupChannel, adminListener, err := adminServer.StartServer(conf)
	if err != nil {
		logger.Fatalw("Failed to start admin server", "error", err)
	}

	adminStartupResult := <-adminStartupChannel
	if !adminStartupResult.Success && adminStartupResult.Error != nil {
		logger.Fatalw("Failed to start admin server", "error", adminStartupResult.Error)
	}

	// update the config with the actual port if the port was 0
	if conf.AdminNetAddressPort() == 0 {
		err := conf.SetAdminNetAddress(adminListener.Addr().String())
		if err != nil {
			logger.Fatalw("failed to set admin net address", "error", err)
		}
		err = conf.WriteToFile(*flagConfigPath)
		if err != nil {
			logger.Fatalw("failed to write config", "error", err)
		}
	}
	// startup message
	cli.PrintServerStarted("Admin server", adminListener.Addr().String())
	// start the phishing servers (HTTP and HTTPS)
	phishingServer := app.NewServer(
		assetPath,
		ownManagedTLSPath,
		db,
		controllers,
		services,
		repositories,
		logger,
		certMagicConfig,
		rbWSPath,
		reportPath,
		conf.IPSecurity.TrustedProxies,
	)

	var r *gin.Engine
	if !build.Flags.Production {
		r = gin.Default()
	} else {
		r = gin.New()
		r.Use(ginzap.GinzapWithConfig(logger.Desugar(), &ginzap.Config{
			TimeFormat: time.RFC3339,
			UTC:        true,
			Context: ginzap.Fn(func(c *gin.Context) []zapcore.Field {
				fields := []zapcore.Field{}
				fields = append(fields, zap.String("host", c.Request.Host))
				fields = append(fields, zap.String("server", "admin"))

				return fields
			}),
		}))
		r.Use(ginzap.RecoveryWithZap(logger.Desugar(), true))
	}

	r.Use(ginzap.GinzapWithConfig(logger.Desugar(), &ginzap.Config{
		TimeFormat: time.RFC3339,
		UTC:        true,
		Context: ginzap.Fn(func(c *gin.Context) []zapcore.Field {
			fields := []zapcore.Field{}
			fields = append(fields, zap.String("host", c.Request.Host))
			fields = append(fields, zap.String("server", "phishing"))

			return fields
		}),
	}))
	r.Use(ginzap.RecoveryWithZap(logger.Desugar(), true))
	phishingServer.AssignRoutes(r)
	// start the HTTP server
	httpTestChan, httpListener, err := phishingServer.StartHTTP(r, conf)
	if err != nil {
		logger.Fatalw("failed to start phishing HTTP server", "error", err)
	}
	httpTestResult := <-httpTestChan
	if !httpTestResult.Success && httpTestResult.Error != nil {
		logger.Fatalw("failed to start phishing HTTP server", "error", httpTestResult.Error)
	}
	cli.PrintServerStarted("Phishing HTTP server", httpListener.Addr().String())
	// start the HTTPSserver
	httpsTestChan, httpsListener, err := phishingServer.StartHTTPS(r, conf)
	if err != nil {
		logger.Fatalw("failed to start HTTPS phishing server", "error", err)
	}
	httpsTestResult := <-httpsTestChan
	if !httpsTestResult.Success && httpsTestResult.Error != nil {
		logger.Fatalw("failed to start HTTPS phishing server", "error", httpsTestResult.Error)
	}
	cli.PrintServerStarted("Phishing HTTPS server", httpsListener.Addr().String())

	// start the task handler
	systemSession, err := model.NewSystemSession()
	if err != nil {
		logger.Fatalw("Failed to load system user", "error", err)
	}
	daemon := task.Runner{
		CampaignService:     services.Campaign,
		UpdateService:       services.Update,
		MicrosoftDeviceCode: services.MicrosoftDeviceCode,
		OptionService:       services.Option,
		RecipientService:    services.Recipient,
		ScimService:         services.Scim,
		Logger:              logger,
	}

	// start tasks runner
	// let the system tasks run once before starting the normal work tasks
	// this ensure that a license check is completed before attempting to send out
	// e-mails as that would cancel the e-mail delivery.
	daemonCtx, cancelDaemons := context.WithCancel(context.Background())
	var wg sync.WaitGroup
	wg.Add(1)
	go daemon.RunSystemTasks(
		daemonCtx,
		systemSession,
		&wg,
	)
	wg.Wait()
	go daemon.Run(
		daemonCtx,
		systemSession,
	)

	// handle aborts and abort signals

	abort := make(chan struct{})
	abortSignalCh := make(chan os.Signal, 1)
	signal.Notify(abortSignalCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGABRT)

	// listen for abort signals
	go func() {
		sig := <-abortSignalCh
		logger.Warnw("Received abort signal - initiating graceful shutdown",
			"signal", sig,
		)

		// Create context with timeout for shutdown
		ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
		defer cancel()

		// Graceful shutdown for daemons
		logger.Debugf("Stopping daemons")
		cancelDaemons()

		// Graceful shutdown for admin server
		logger.Debugf("Stopping administration server")
		if err := adminServer.Server.Shutdown(ctx); err != nil {
			logger.Errorw("Admin server shutdown error", "error", err)
		}

		// Graceful shutdown for HTTP server
		logger.Debugf("Stopping HTTP Phishing server")
		if err := phishingServer.HTTPServer.Shutdown(ctx); err != nil {
			logger.Errorw("HTTP server shutdown error", "error", err)
		}

		// Graceful shutdown for HTTPS server
		logger.Debugf("Stopping HTTPS Phishing server")
		if err := phishingServer.HTTPSServer.Shutdown(ctx); err != nil {
			logger.Errorw("HTTPS server shutdown error", "error", err)
		}

		// Close database connections
		sqlDB, err := db.DB()
		if err != nil {
			logger.Errorw("Error getting DB instance", "error", err)
		} else {
			if err := sqlDB.Close(); err != nil {
				logger.Errorw("Error closing database", "error", err)
			}
		}

		logger.Info("Graceful shutdown completed")
		close(abort)
	}()

	logger.Debug("Waiting for abort signal")
	<-abort
}

func interactiveAccountRecovery(repositories *app.Repositories, utils *app.Utilities) {
	// check if we are in the same folder as the binary is in
	ex, err := os.Executable()
	if err != nil {
		_, _ = fmt.Printf("Error getting executable path: %s\n", err)
		return
	}
	binPath := filepath.Dir(ex)
	currentPath, err := os.Getwd()
	if err != nil {
		_, _ = fmt.Printf("Error getting current directory: %s\n", err)
		return
	}
	if binPath != currentPath {
		_, _ = fmt.Printf("Please run this command from the same directory as the binary (%s)\n", binPath)
		return
	}

	// get the username to recover
	var user *model.User
	for user == nil {
		account := ""
		_, _ = fmt.Print("Enter account username: ")
		_, _ = fmt.Scanln(&account)
		username, err := vo.NewUsername(account)
		if err != nil {
			_, _ = fmt.Println("Invalid username")
			continue
		}
		user, err = repositories.User.GetByUsername(
			context.TODO(),
			username,
			&repository.UserOption{},
		)
		if err != nil {
			_, _ = fmt.Printf("Could not find username: %s\n", err)
			continue
		}
		_, _ = fmt.Println("User found")
	}
	uid := user.ID.MustGet()
	for {
		passwordInput := ""
		passwordConfirmInput := ""
		_, _ = fmt.Print("New password: ")
		_, _ = fmt.Scanln(&passwordInput)
		_, _ = fmt.Print("Confirm password: ")
		_, _ = fmt.Scanln(&passwordConfirmInput)
		if passwordInput != passwordConfirmInput {
			_, _ = fmt.Println("Repeated password does not match")
			continue
		}
		newPassword, err := vo.NewReasonableLengthPassword(passwordInput)
		if err != nil {
			_, _ = fmt.Printf("Error in password: %s\n", err)
			continue
		}
		hash, err := utils.PasswordHasher.Hash(newPassword.String())
		if err != nil {
			_, _ = fmt.Printf("Failed to hash password: %s\n", err)
			continue
		}
		err = repositories.User.UpdatePasswordHashByID(
			context.TODO(),
			&uid,
			hash,
		)
		if err != nil {
			_, _ = fmt.Printf("Failed to update password: %s\n", err)
			continue
		}
		_, _ = fmt.Println("Password updated")
		break
	}
	// remove any SSO or TOTP related data
	user.SSOID = nullable.NewNullableWithValue("")
	err = repositories.User.RemoveTOTP(context.TODO(), &uid)
	if err != nil {
		_, _ = fmt.Println("Failed to remove TOTP:", err)
	}
	err = repositories.User.UpdateUserToNoSSO(context.TODO(), &uid)
	if err != nil {
		_, _ = fmt.Println("Failed to remove TOTP:", err)
	}
}

func setLogLevels(db *gorm.DB, atomicLogger *zap.AtomicLevel, forceDebug bool) error {
	// if debug flag is set, force debug logging and ignore db settings
	if forceDebug {
		db.Logger = db.Logger.LogMode(gormLogger.Info)
		atomicLogger.SetLevel(zap.DebugLevel)
		return nil
	}

	// set log levels from DB for logger and db logger
	var dbLogLevel database.Option
	res := db.
		Where("key = ?", data.OptionKeyDBLogLevel).
		First(&dbLogLevel)

	if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) {
		return fmt.Errorf("failed to get DB log level: %w", res.Error)
	}
	switch dbLogLevel.Value {
	case "silent":
		db.Logger = db.Logger.LogMode(gormLogger.Silent)
	case "info":
		db.Logger = db.Logger.LogMode(gormLogger.Info)
	case "warn":
		db.Logger = db.Logger.LogMode(gormLogger.Warn)
	case "error":
		db.Logger = db.Logger.LogMode(gormLogger.Error)
	}
	var logLevel database.Option
	res = db.
		Where("key = ?", data.OptionKeyLogLevel).
		First(&logLevel)
	if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) {
		return fmt.Errorf("failed to get log level: %w", res.Error)
	}
	switch logLevel.Value {
	case "debug":
		atomicLogger.SetLevel(zap.DebugLevel)
	case "info":
		atomicLogger.SetLevel(zap.InfoLevel)
	case "warn":
		atomicLogger.SetLevel(zap.WarnLevel)
	case "error":
		atomicLogger.SetLevel(zap.ErrorLevel)
	}
	return nil
}
