// Package bootstrap provisions the first owner of a fresh install.
//
// Without it the only documented path was: register through the public form,
// read the emailed code out of a mail catcher, then run a psql UPDATE from the
// host. That is three manual steps, it depends on working mail, and the window
// between "instance is reachable" and "an admin exists" is claimable by anyone
// who finds the URL first, which is a recurring CVE class in self-hosted
// software.
//
// Two mechanisms, both standard in the field:
//
//   - WARMBLY_BOOTSTRAP_EMAIL plus WARMBLY_BOOTSTRAP_PASSWORD_HASH, read only
//     while the users table is empty (authentik, n8n and NocoDB all do this).
//   - Otherwise a single-use setup token printed once to the logs, which is
//     Zulip's realm-creation link, invalidated the moment it is used.
package bootstrap

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"log"
	"net/mail"
	"os"
	"strings"
	"time"

	"github.com/getsentry/sentry-go"
	"github.com/warmbly/warmbly/internal/app/organization"
	"github.com/warmbly/warmbly/internal/app/trial"
	"github.com/warmbly/warmbly/internal/app/user"
	"github.com/warmbly/warmbly/internal/config"
	"github.com/warmbly/warmbly/internal/errx"
	"github.com/warmbly/warmbly/internal/infrastructure/cache"
	"github.com/warmbly/warmbly/internal/models"
	"github.com/warmbly/warmbly/internal/pkg/argon2"
	"github.com/warmbly/warmbly/internal/pkg/crypt"
	"github.com/warmbly/warmbly/internal/repository"
)

// SetupTokenTTL bounds the window in which the printed token can be claimed.
const SetupTokenTTL = 24 * time.Hour

const setupTokenKey = "bootstrap:setup_token"

type Service struct {
	users     repository.UserRepository
	userSvc   user.UserService
	orgSvc    organization.OrganizationService
	trialSvc  trial.TrialService
	adminRepo repository.AdminRepository
	cache     *cache.Cache
}

func NewService(
	users repository.UserRepository,
	userSvc user.UserService,
	orgSvc organization.OrganizationService,
	trialSvc trial.TrialService,
	adminRepo repository.AdminRepository,
	cache *cache.Cache,
) *Service {
	return &Service{
		users:     users,
		userSvc:   userSvc,
		orgSvc:    orgSvc,
		trialSvc:  trialSvc,
		adminRepo: adminRepo,
		cache:     cache,
	}
}

// Run is called once at boot, after migrations. It is a no-op on an instance
// that already has users, so it is safe on every restart.
func (s *Service) Run(ctx context.Context) error {
	empty, err := s.users.IsEmpty(ctx)
	if err != nil {
		return fmt.Errorf("bootstrap: checking for existing users: %w", err)
	}
	if !empty {
		// Say why no claim link appeared. Staying silent here is what turns a
		// re-used database into an unexplained 403 on the signup form.
		s.explainClaimed(ctx)
		return nil
	}

	email := strings.TrimSpace(os.Getenv("WARMBLY_BOOTSTRAP_EMAIL"))
	if email != "" {
		return s.createOwner(ctx, email)
	}

	return s.printSetupToken(ctx)
}

func (s *Service) createOwner(ctx context.Context, address string) error {
	parsed, err := mail.ParseAddress(address)
	if err != nil {
		return fmt.Errorf("bootstrap: WARMBLY_BOOTSTRAP_EMAIL is not a valid address: %w", err)
	}

	hash := strings.TrimSpace(os.Getenv("WARMBLY_BOOTSTRAP_PASSWORD_HASH"))
	if hash == "" {
		// A plaintext password is accepted as a convenience, with the same
		// warning authentik gives: environment variables leak through process
		// listings, orchestrator APIs and crash dumps.
		plain := os.Getenv("WARMBLY_BOOTSTRAP_PASSWORD")
		if plain == "" {
			return fmt.Errorf("bootstrap: WARMBLY_BOOTSTRAP_EMAIL is set but neither WARMBLY_BOOTSTRAP_PASSWORD_HASH nor WARMBLY_BOOTSTRAP_PASSWORD is")
		}
		log.Printf("Warning: WARMBLY_BOOTSTRAP_PASSWORD is a plaintext password in an environment variable. Prefer WARMBLY_BOOTSTRAP_PASSWORD_HASH.")
		hashed, herr := argon2.Hash(plain)
		if herr != nil {
			return fmt.Errorf("bootstrap: hashing the password: %w", herr)
		}
		hash = hashed
	}

	u, uerr := s.users.CreateUser(ctx, parsed, hash)
	if uerr != nil {
		return fmt.Errorf("bootstrap: creating the owner: %w", uerr)
	}
	if err := s.userSvc.SaveUser(ctx, u); err != nil {
		return fmt.Errorf("bootstrap: saving the owner: %w", err)
	}

	orgName := strings.TrimSpace(os.Getenv("WARMBLY_BOOTSTRAP_ORG"))
	if orgName == "" {
		orgName = defaultOrgName(u.FirstName)
	}
	org, orgErr := s.orgSvc.Create(ctx, u.ID, orgName)
	if orgErr != nil {
		return fmt.Errorf("bootstrap: creating the organization: %w", orgErr)
	}
	if s.trialSvc != nil {
		_ = s.trialSvc.StartFreeTrialWithOrg(ctx, u.ID, org.ID)
	}

	// The bootstrap account is the platform operator, so it gets full admin.
	// This is the only path that grants admin without an existing admin.
	if err := s.adminRepo.GrantBootstrapAdmin(ctx, u.ID, uint32(models.AllAdminPermissions)); err != nil {
		return fmt.Errorf("bootstrap: granting admin: %w", err)
	}

	log.Printf("Bootstrap: created owner %s with full admin permissions and organization %q.", parsed.Address, orgName)
	return nil
}

// IssueSetupLink mints a single-use claim link for the first account and
// returns it. Only its hash is stored, so reading the database does not yield a
// usable token, and claiming it deletes the key. Minting again replaces any
// outstanding token, so an old link stops working.
//
// It refuses on a claimed instance: a second owner must never be mintable
// without an existing account.
func (s *Service) IssueSetupLink(ctx context.Context) (string, error) {
	if s.cache == nil {
		return "", fmt.Errorf("bootstrap: no cache is configured, so a setup token cannot be stored (check REDIS)")
	}

	empty, err := s.users.IsEmpty(ctx)
	if err != nil {
		return "", fmt.Errorf("bootstrap: checking for existing users: %w", err)
	}
	if !empty {
		return "", fmt.Errorf("bootstrap: this instance already has accounts, so no setup link is issued. Add an account with `warmblyctl user create --email you@example.com --admin`")
	}

	buf := make([]byte, 32)
	if _, err := rand.Read(buf); err != nil {
		return "", err
	}
	token := hex.EncodeToString(buf)

	if err := s.cache.SetEx(ctx, setupTokenKey, hashToken(token), SetupTokenTTL).Err(); err != nil {
		return "", fmt.Errorf("bootstrap: storing the setup token: %w", err)
	}

	return fmt.Sprintf("%s/setup?token=%s", config.AppBaseURL(), token), nil
}

// printSetupToken mints the claim link at boot and prints it once.
func (s *Service) printSetupToken(ctx context.Context) error {
	if s.cache == nil {
		// The worst possible silence: without a cache the instance can neither
		// be claimed nor registered against, and nothing else says so.
		log.Printf("Bootstrap: no accounts exist and no cache is configured, so no setup link can be issued. Check REDIS, then run `warmblyctl setup-link`.")
		return nil
	}

	url, err := s.IssueSetupLink(ctx)
	if err != nil {
		return err
	}

	log.Printf("\n"+
		"┌──────────────────────────────────────────────────────────────────────\n"+
		"│ No accounts exist yet. Claim this instance with the link below.\n"+
		"│\n"+
		"│   %s\n"+
		"│\n"+
		"│ Single use, valid for %s. It is printed only once, and only its hash\n"+
		"│ is stored.\n"+
		"│\n"+
		"│ Lost it? Print a new one: warmblyctl setup-link\n"+
		"│ Unattended instead? Set WARMBLY_BOOTSTRAP_EMAIL and\n"+
		"│ WARMBLY_BOOTSTRAP_PASSWORD_HASH before the first start.\n"+
		"└──────────────────────────────────────────────────────────────────────\n",
		url, SetupTokenTTL)

	return nil
}

// explainClaimed logs why a claim link was not issued, and what to run
// instead. Best effort: never blocks boot.
func (s *Service) explainClaimed(ctx context.Context) {
	count, err := s.users.CountUsers(ctx)
	if err != nil {
		return
	}

	registration := config.LoadAuthPolicy(false).Registration
	advice := "Registration is open, so you can create an account from the sign-in page."
	if registration != config.RegistrationOpen {
		advice = fmt.Sprintf("Registration is %s (DISABLE_REGISTRATION), so the signup form will refuse\n"+
			"│ new accounts. Add one with:\n"+
			"│\n"+
			"│   warmblyctl user create --email you@example.com --admin", registration)
	}

	log.Printf("\n"+
		"┌──────────────────────────────────────────────────────────────────────\n"+
		"│ First run: skipped. This instance already has %d account(s), so no\n"+
		"│ setup link is issued.\n"+
		"│\n"+
		"│ %s\n"+
		"│\n"+
		"│ See where you stand: warmblyctl status\n"+
		"└──────────────────────────────────────────────────────────────────────\n",
		count, advice)
}

// Required reports whether this instance still needs to be claimed. Drives the
// setup page and the `setup_required` flag on GET /auth/config.
func (s *Service) Required(ctx context.Context) bool {
	empty, err := s.users.IsEmpty(ctx)
	return err == nil && empty
}

// Claim exchanges the printed setup token for the owner account.
//
// The token is consumed before the account is created, not after: two requests
// racing with the same token must not both succeed, and Redis GETDEL is the
// atomic step that decides which one wins.
func (s *Service) Claim(ctx context.Context, token, address, password, firstName, lastName string) (*models.User, *errx.Error) {
	if s.cache == nil || token == "" {
		return nil, errx.ErrSetupToken
	}

	parsed, perr := mail.ParseAddress(address)
	if perr != nil {
		return nil, errx.ErrEmail
	}
	if !crypt.ValidatePassword(password) {
		return nil, errx.ErrPassword
	}

	// Refuse on an instance that already has accounts, even with a valid
	// token: a stale link out of an old log must never mint a second owner.
	empty, eerr := s.users.IsEmpty(ctx)
	if eerr != nil {
		sentry.CaptureException(eerr)
		return nil, errx.InternalError()
	}
	if !empty {
		return nil, errx.ErrSetupComplete
	}

	// Read the remaining lifetime before consuming the key: restoring a losing
	// token with a fresh TTL would let anyone hold the claim window open
	// indefinitely by posting a wrong token once a day, and /auth/setup is
	// public.
	remaining, _ := s.cache.PTTL(ctx, setupTokenKey).Result()

	stored, gerr := s.cache.GetDel(ctx, setupTokenKey).Result()
	if gerr != nil || stored == "" || stored != hashToken(token) {
		// Put a valid-but-losing token back only when the value did not match,
		// so a typo does not burn the real one, and only for the time it had
		// left.
		if gerr == nil && stored != "" && stored != hashToken(token) && remaining > 0 {
			_ = s.cache.SetEx(ctx, setupTokenKey, stored, remaining).Err()
		}
		return nil, errx.ErrSetupToken
	}

	hash, herr := argon2.Hash(password)
	if herr != nil {
		sentry.CaptureException(herr)
		return nil, errx.InternalError()
	}

	u, uerr := s.users.CreateUser(ctx, parsed, hash)
	if uerr != nil {
		sentry.CaptureException(uerr)
		return nil, errx.InternalError()
	}
	if firstName != "" {
		if err := s.users.UpdateProfile(ctx, u.ID, firstName, lastName); err == nil {
			u.FirstName, u.LastName = firstName, lastName
		}
	}
	if err := s.userSvc.SaveUser(ctx, u); err != nil {
		return nil, err
	}

	orgName := strings.TrimSpace(os.Getenv("WARMBLY_BOOTSTRAP_ORG"))
	if orgName == "" {
		orgName = defaultOrgName(u.FirstName)
	}
	org, orgErr := s.orgSvc.Create(ctx, u.ID, orgName)
	if orgErr != nil {
		sentry.CaptureException(orgErr)
		return nil, errx.InternalError()
	}
	if s.trialSvc != nil {
		_ = s.trialSvc.StartFreeTrialWithOrg(ctx, u.ID, org.ID)
	}

	if err := s.adminRepo.GrantBootstrapAdmin(ctx, u.ID, uint32(models.AllAdminPermissions)); err != nil {
		sentry.CaptureException(err)
		return nil, errx.InternalError()
	}

	log.Printf("Setup: %s claimed this instance and is now its owner and platform admin.", parsed.Address)
	return u, nil
}

func defaultOrgName(firstName string) string {
	if firstName == "" {
		return "My Organization"
	}
	return firstName + "'s Organization"
}

func hashToken(token string) string {
	// argon2 is overkill for a 256-bit random token, so the shared SHA-256
	// helper is the right cost here.
	return crypt.SHA256(token)
}
