// Package claim is the customer-claim password-code engine (v0.50.0, closes DRILL-day0-vm F-4).
// The hub generates a one-time claim code, emails it (Hungarian) to the REGISTERED customer
// address, and stores only bcrypt(code) — the plaintext exists solely inside the email send
// (the retrieval-passphrase custody rule). The controller receives the hash (+ generation)
// baked into controller.yaml at Day-0 and via the report ACK for live boxes, and gates its
// dashboard until the customer claims it by setting their own password. Reset rides the same
// engine: a rotation bumps the generation (single active code) and NEVER clears claimed_at.
package claim

import (
	"fmt"
	"log"

	"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
	"golang.org/x/crypto/bcrypt"
)

// codeWords is the claim/reset code length: 3 Hungarian words (~44 bits with the ~29K list) —
// dictatable over the phone, and the controller's 5-attempt/15-min lockout makes online guessing
// infeasible.
const codeWords = 3

// maxResetPerDay is the hub-side cap on controller-forwarded reset requests (per customer).
const maxResetPerDay = 3

// EmailKind selects the Hungarian template for a code delivery.
type EmailKind string

const (
	EmailClaim   EmailKind = "claim"   // first setup: "Elindult a Felhom szervered"
	EmailReset   EmailKind = "reset"   // forgotten password — the box HAS a password
	EmailClaimed EmailKind = "claimed" // confirmation after a successful claim (carries no code)
	// EmailReenroll — the box was wiped and re-enrolled, so the fresh controller has NO password
	// while the hub-side claim is still set (ReissueForReenroll).
	//
	// R-295 (hub half, 2026-08-13): THIS EXISTS BECAUSE THE MAIL MUST NAME THE PAGE THE MACHINE IS
	// ACTUALLY SHOWING. Both situations deliver the same secret and it keeps the same name — the
	// three-word „Beállító kód" — but they do NOT show the same screen, and only the hub can tell
	// them apart, because it is the hub that chose which call site fired. A rebuilt box renders
	// „A szerver beállítása" (controller `web/claim.go:279`: `reset := s.authEnabled()`, and a fresh
	// controller has no password), and it serves no login page — so it has no „Elfelejtett jelszó"
	// link at all. Sending a re-enrolled customer to that page names a route that is not on their
	// screen. Splitting the KIND rather than the NAME is what the ruling asks for: one secret in two
	// situations keeps its name, and the sentence around it changes.
	EmailReenroll EmailKind = "reenroll"
)

// Mailer delivers a claim-arc email. The code is passed through and MUST NOT be persisted or
// logged by implementations (empty for EmailClaimed). kind is one of the EmailKind constants
// (plain string in the signature so implementations need no import of this package).
type Mailer interface {
	SendClaimEmail(kind, customerID, email, domain, code string) error
}

// Engine composes the store, the code generator and the mailer. All methods are idempotent or
// explicitly rotating; none ever stores or logs a plaintext code.
type Engine struct {
	Store  *store.Store
	Mailer Mailer
	Logger *log.Logger
}

func (e *Engine) logf(f string, a ...any) {
	if e.Logger != nil {
		e.Logger.Printf(f, a...)
	}
}

// newCode generates a fresh code and its bcrypt hash.
func newCode() (code, hash string, err error) {
	code, err = configgen.RandomPassphrase(codeWords)
	if err != nil {
		return "", "", fmt.Errorf("claim: generating code: %w", err)
	}
	h, err := bcrypt.GenerateFromPassword([]byte(code), 10)
	if err != nil {
		return "", "", fmt.Errorf("claim: hashing code: %w", err)
	}
	return code, string(h), nil
}

// rotateAndSend rotates the code (generation+1 / create) and emails it. The email failure path
// keeps the rotated hash (the gate stays armed) and is LOUD: the operator sees emailed_at unset
// on the customer page and can resend. Returns the new generation.
func (e *Engine) rotateAndSend(cc *store.CustomerConfig, kind EmailKind) (int, error) {
	code, hash, err := newCode()
	if err != nil {
		return 0, err
	}
	gen, err := e.Store.RotateClaimCode(cc.CustomerID, hash)
	if err != nil {
		return 0, fmt.Errorf("claim: storing code hash: %w", err)
	}
	if cc.Email == "" {
		e.logf("[ERROR] [claim] %s code generated (gen %d) but customer %s has NO registered email — deliver via resend after setting one", kind, gen, cc.CustomerID)
		return gen, fmt.Errorf("claim: customer %s has no registered email", cc.CustomerID)
	}
	if err := e.Mailer.SendClaimEmail(string(kind), cc.CustomerID, cc.Email, cc.Domain, code); err != nil {
		e.logf("[ERROR] [claim] %s code email to customer %s FAILED (gate stays armed; resend from the customer page): %v", kind, cc.CustomerID, err)
		return gen, fmt.Errorf("claim: sending %s email: %w", kind, err)
	}
	if err := e.Store.MarkClaimEmailed(cc.CustomerID); err != nil {
		e.logf("[WARN] [claim] %s code sent to customer %s but emailed_at not recorded: %v", kind, cc.CustomerID, err)
	}
	e.logf("[INFO] [claim] %s code (gen %d) emailed to the registered address of %s", kind, gen, cc.CustomerID)
	return gen, nil
}

// EnsureIssued guarantees a claim row exists for the customer, issuing + emailing the first code
// when absent. An existing row (any state) is returned untouched — repeated config pulls and
// reports never rotate or re-send. This is the Day-0 entry point (API config retrieve) and the
// live-box entry point (first report of an upgrading fleet).
func (e *Engine) EnsureIssued(cc *store.CustomerConfig) (*store.ClaimState, error) {
	cs, err := e.Store.GetClaim(cc.CustomerID)
	if err != nil {
		return nil, fmt.Errorf("claim: reading state: %w", err)
	}
	if cs != nil {
		return cs, nil
	}
	if _, err := e.rotateAndSend(cc, EmailClaim); err != nil {
		// The hash (if stored) still arms the gate; surface the error for the caller's log.
		cs, gerr := e.Store.GetClaim(cc.CustomerID)
		if gerr == nil && cs != nil {
			return cs, err
		}
		return nil, err
	}
	return e.Store.GetClaim(cc.CustomerID)
}

// Resend rotates the code and re-sends it — the operator "Kód újraküldése" button. An unclaimed
// customer gets the claim template; a claimed one gets the reset template (the only code a
// claimed box can consume is a reset).
func (e *Engine) Resend(cc *store.CustomerConfig) error {
	cs, err := e.Store.GetClaim(cc.CustomerID)
	if err != nil {
		return fmt.Errorf("claim: reading state: %w", err)
	}
	kind := EmailClaim
	if cs.Claimed() {
		kind = EmailReset
	}
	_, err = e.rotateAndSend(cc, kind)
	return err
}

// RequestReset handles a controller-forwarded "Elfelejtett jelszó": rate-limited hub-side
// (maxResetPerDay per customer), then rotates + emails a reset code. The neutral customer-facing
// response ("ha az e-mail cím regisztrálva van…") is the CALLER's job — this returns real errors
// for the operator log.
func (e *Engine) RequestReset(cc *store.CustomerConfig) error {
	cs, err := e.Store.GetClaim(cc.CustomerID)
	if err != nil {
		return fmt.Errorf("claim: reading state: %w", err)
	}
	if cs == nil {
		// No claim row yet (pre-arc box asking for a reset): issue the first code instead.
		_, err := e.EnsureIssued(cc)
		return err
	}
	count, err := e.Store.BumpResetCount(cc.CustomerID)
	if err != nil {
		return fmt.Errorf("claim: reset limiter: %w", err)
	}
	if count > maxResetPerDay {
		e.logf("[WARN] [claim] reset request for %s REFUSED: daily cap reached (%d/%d)", cc.CustomerID, count, maxResetPerDay)
		return fmt.Errorf("claim: reset cap reached for %s (%d/day)", cc.CustomerID, maxResetPerDay)
	}
	_, err = e.rotateAndSend(cc, EmailReset)
	return err
}

// ReissueForReenroll handles the clean-slate reinstall of a CLAIMED customer (F2, v0.57.0): the box
// (host + in-guest controller) was wiped and re-enrolls, so the fresh controller has NO password
// while the hub-side claim is set. This rotates + emails a RESET code (rides Resend's rotation
// semantics — a single generation bump, single active code) so the customer gets a fresh code
// automatically instead of hunting for the manual "request new code" button. The new hash reaches
// the fresh controller through the existing report ACK.
//
// No-op for an UNCLAIMED customer — that is the first-provision path where EnsureIssued already
// owns the first code; re-enrolling before the first claim must NOT rotate. The CALLER guarantees
// single-shot by invoking this only on a genuinely fresh host record (the host-enroll mint path,
// which fires exactly once per reinstall). Returns (generation, reissued, error).
func (e *Engine) ReissueForReenroll(cc *store.CustomerConfig) (gen int, reissued bool, err error) {
	cs, err := e.Store.GetClaim(cc.CustomerID)
	if err != nil {
		return 0, false, fmt.Errorf("claim: reading state: %w", err)
	}
	if cs == nil || !cs.Claimed() {
		return 0, false, nil // unclaimed → first-provision path; nothing to re-issue
	}
	// EmailReenroll, not EmailReset: same secret, same name, different screen — see the constant.
	gen, err = e.rotateAndSend(cc, EmailReenroll)
	if err != nil {
		return gen, true, err // reissued=true so the caller records the attempt even on email failure
	}
	e.logf("[INFO] [claim] reset code re-issued (gen %d) for %s on box re-enrollment (clean-slate reinstall)", gen, cc.CustomerID)
	return gen, true, nil
}

// ResetToUnclaimed returns a customer to the pre-first-install (unclaimed, no active code) state — the
// customer-RESET leg (v0.61.0). It DELETES the claim row so the engine's EnsureIssued mints a FRESH
// first code (generation reset, unclaimed) on the next onboarding: no parallel "revoked" flag, no stale
// generation. The active code is invalidated (the row is gone → the gate has no hash) and claimed_at is
// cleared (gone). Idempotent (a missing row is a no-op).
func (e *Engine) ResetToUnclaimed(cc *store.CustomerConfig) error {
	if err := e.Store.DeleteClaim(cc.CustomerID); err != nil {
		return fmt.Errorf("claim: reset to unclaimed: %w", err)
	}
	e.logf("[INFO] [claim] reset to unclaimed for %s (customer RESET) — next onboarding mints a fresh code", cc.CustomerID)
	return nil
}

// defaultSeedEvents is the enabled_events set seeded at claim (v0.71.0, audit F12) — critical-only:
// no node_stale (too chatty), no *_recovered (the recovery pairing gate handles those and never
// consults enabled_events). Viktor may adjust the list at review.
var defaultSeedEvents = []string{
	"node_down",
	"backup_failed",
	"disk_critical",
	"host_disk_critical",
	"storage_fill_critical",
	"offbox_repo_orphaned",
}

// MarkClaimed records a controller-reported successful claim and sends the one-time confirmation
// email on the unclaimed→claimed transition (idempotent — repeated reports are no-ops). On the
// transition it also seeds notification prefs from the registered email (v0.71.0, audit F12) so a
// claimed customer can no longer be silently unnotifiable — insert-if-absent, never overwriting a
// customer-edited row, and never failing the claim (notification plumbing must not gate claiming).
func (e *Engine) MarkClaimed(cc *store.CustomerConfig) error {
	transitioned, err := e.Store.MarkClaimed(cc.CustomerID)
	if err != nil {
		return fmt.Errorf("claim: marking claimed: %w", err)
	}
	if !transitioned {
		return nil
	}
	e.logf("[INFO] [claim] customer %s CLAIMED its dashboard (password set by the customer)", cc.CustomerID)
	if seeded, err := e.Store.SeedNotificationPrefs(cc.CustomerID, cc.Email, defaultSeedEvents); err != nil {
		e.logf("[WARN] [claim] notification-prefs seed for %s failed (claim unaffected): %v", cc.CustomerID, err)
	} else if seeded {
		e.logf("[INFO] [claim] notification prefs seeded for %s from the registered email (default critical set)", cc.CustomerID)
	} else {
		e.logf("[INFO] [claim] notification prefs for %s left untouched (row exists or no registered email)", cc.CustomerID)
	}
	if cc.Email != "" {
		if err := e.Mailer.SendClaimEmail(string(EmailClaimed), cc.CustomerID, cc.Email, cc.Domain, ""); err != nil {
			e.logf("[WARN] [claim] claimed-confirmation email to %s failed: %v", cc.CustomerID, err)
		}
	}
	return nil
}
