package claim

import (
	"io"
	"log"
	"path/filepath"
	"strings"
	"testing"

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

// fakeMailer records sends and captures the LAST code (test-only — the engine itself never
// retains one). failNext makes the next send fail.
type fakeMailer struct {
	sends    []string // "kind:customerID:email"
	lastCode string
	failNext bool
}

func (f *fakeMailer) SendClaimEmail(kind, customerID, email, domain, code string) error {
	if f.failNext {
		f.failNext = false
		return errSend
	}
	f.sends = append(f.sends, kind+":"+customerID+":"+email)
	f.lastCode = code
	return nil
}

var errSend = &sendErr{}

type sendErr struct{}

func (*sendErr) Error() string { return "send failed" }

func newTestEngine(t *testing.T) (*Engine, *store.Store, *fakeMailer) {
	t.Helper()
	st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
	if err != nil {
		t.Fatalf("store.New: %v", err)
	}
	t.Cleanup(func() { st.Close() })
	m := &fakeMailer{}
	return &Engine{Store: st, Mailer: m, Logger: log.New(io.Discard, "", 0)}, st, m
}

func cust() *store.CustomerConfig {
	return &store.CustomerConfig{CustomerID: "c1", Email: "owner@example.hu", Domain: "example.hu"}
}

// EnsureIssued creates the row + emails ONCE; repeated calls neither rotate nor re-send.
func TestEnsureIssued_IdempotentSingleEmail(t *testing.T) {
	e, st, m := newTestEngine(t)
	cs, err := e.EnsureIssued(cust())
	if err != nil {
		t.Fatalf("EnsureIssued: %v", err)
	}
	if cs == nil || cs.Generation != 1 || cs.CodeHash == "" {
		t.Fatalf("first issue: got %+v", cs)
	}
	if len(m.sends) != 1 || !strings.HasPrefix(m.sends[0], "claim:c1:") {
		t.Fatalf("expected exactly one claim email, got %v", m.sends)
	}
	// The stored hash must verify the emailed code and must NOT contain it (bcrypt-only custody).
	if bcrypt.CompareHashAndPassword([]byte(cs.CodeHash), []byte(m.lastCode)) != nil {
		t.Fatal("stored hash does not verify the emailed code")
	}
	if strings.Contains(cs.CodeHash, m.lastCode) {
		t.Fatal("plaintext code leaked into the stored hash")
	}

	cs2, err := e.EnsureIssued(cust())
	if err != nil {
		t.Fatalf("EnsureIssued (second): %v", err)
	}
	if cs2.Generation != 1 || cs2.CodeHash != cs.CodeHash {
		t.Fatalf("second EnsureIssued must not rotate: gen %d hash-changed=%v", cs2.Generation, cs2.CodeHash != cs.CodeHash)
	}
	if len(m.sends) != 1 {
		t.Fatalf("second EnsureIssued must not re-send: %v", m.sends)
	}
	// §10 bcrypt-only storage: the DB row itself never contains the plaintext.
	row, err := st.GetClaim("c1")
	if err != nil || row == nil {
		t.Fatalf("GetClaim: %v %v", row, err)
	}
	if strings.Contains(row.CodeHash, m.lastCode) || row.CodeHash == m.lastCode {
		t.Fatal("DB row contains the plaintext code")
	}
}

// Resend rotates: generation bumps, the OLD code no longer verifies against the stored hash
// (single active code). Red-proof partner: drop the generation bump in RotateClaimCode → fails.
func TestResend_RotatesAndInvalidatesOldCode(t *testing.T) {
	e, st, m := newTestEngine(t)
	if _, err := e.EnsureIssued(cust()); err != nil {
		t.Fatalf("EnsureIssued: %v", err)
	}
	oldCode := m.lastCode
	if err := e.Resend(cust()); err != nil {
		t.Fatalf("Resend: %v", err)
	}
	cs, _ := st.GetClaim("c1")
	if cs.Generation != 2 {
		t.Fatalf("generation after resend = %d, want 2", cs.Generation)
	}
	if bcrypt.CompareHashAndPassword([]byte(cs.CodeHash), []byte(oldCode)) == nil {
		t.Fatal("OLD code still verifies after resend — single-active-code broken")
	}
	if bcrypt.CompareHashAndPassword([]byte(cs.CodeHash), []byte(m.lastCode)) != nil {
		t.Fatal("new code does not verify after resend")
	}
	if len(m.sends) != 2 || !strings.HasPrefix(m.sends[1], "claim:") {
		t.Fatalf("unclaimed resend should use the claim template: %v", m.sends)
	}
}

// A claimed customer's resend uses the RESET template and never clears claimed_at.
func TestResend_ClaimedGetsResetTemplateAndStaysClaimed(t *testing.T) {
	e, st, m := newTestEngine(t)
	if _, err := e.EnsureIssued(cust()); err != nil {
		t.Fatalf("EnsureIssued: %v", err)
	}
	if err := e.MarkClaimed(cust()); err != nil {
		t.Fatalf("MarkClaimed: %v", err)
	}
	if err := e.Resend(cust()); err != nil {
		t.Fatalf("Resend: %v", err)
	}
	cs, _ := st.GetClaim("c1")
	if !cs.Claimed() {
		t.Fatal("rotation cleared claimed_at — resets must never un-claim")
	}
	last := m.sends[len(m.sends)-1]
	if !strings.HasPrefix(last, "reset:") {
		t.Fatalf("claimed resend should use the reset template, got %s", last)
	}
}

// v0.57.0 (F2) — ReissueForReenroll rotates + emails a RESET code for a CLAIMED customer whose box
// was clean-slate reinstalled (fresh box has no password), and is a NO-OP for an unclaimed customer
// (the first-provision path, where EnsureIssued owns the first code — re-enrolling must not rotate).
func TestReissueForReenroll(t *testing.T) {
	t.Run("claimed rotates and sends the reset template", func(t *testing.T) {
		e, st, m := newTestEngine(t)
		if _, err := e.EnsureIssued(cust()); err != nil {
			t.Fatalf("EnsureIssued: %v", err)
		}
		if err := e.MarkClaimed(cust()); err != nil {
			t.Fatalf("MarkClaimed: %v", err)
		}
		sendsBefore := len(m.sends)
		gen, reissued, err := e.ReissueForReenroll(cust())
		if err != nil {
			t.Fatalf("ReissueForReenroll: %v", err)
		}
		if !reissued {
			t.Fatal("a CLAIMED customer must re-issue a code on box re-enrollment")
		}
		cs, _ := st.GetClaim("c1")
		if gen < 2 || cs.Generation != gen {
			t.Fatalf("re-enroll must bump the generation once: gen=%d stored=%d", gen, cs.Generation)
		}
		if !cs.Claimed() {
			t.Fatal("re-issue must NEVER un-claim (reset rides rotation)")
		}
		// R-295 hub half (2026-08-13): this used to assert "reset:". The KIND was split — same
		// secret, same name („Beállító kód"), different SENTENCE — because a rebuilt box has no
		// password, so it shows „A szerver beállítása" and serves no login page, and the reset mail
		// sent the customer to an „Elfelejtett jelszó" page that is not on their screen. The
		// assertion is deliberately kept STRICT rather than loosened to "either kind": routing a
		// re-enrolment back down the reset copy is exactly the regression worth failing on.
		if len(m.sends) != sendsBefore+1 || !strings.HasPrefix(m.sends[len(m.sends)-1], "reenroll:") {
			t.Fatalf("claimed re-enroll must send exactly one REENROLL email, got %v", m.sends)
		}
	})
	t.Run("unclaimed is a no-op (first-provision path)", func(t *testing.T) {
		e, _, m := newTestEngine(t)
		if _, err := e.EnsureIssued(cust()); err != nil { // issued but NOT claimed
			t.Fatalf("EnsureIssued: %v", err)
		}
		sendsBefore := len(m.sends)
		_, reissued, err := e.ReissueForReenroll(cust())
		if err != nil {
			t.Fatalf("ReissueForReenroll: %v", err)
		}
		if reissued {
			t.Fatal("an UNCLAIMED customer must NOT re-issue on re-enroll (first provision owns the code)")
		}
		if len(m.sends) != sendsBefore {
			t.Fatalf("no email may be sent on an unclaimed re-enroll, got %v", m.sends)
		}
	})
}

// RequestReset caps at 3/day per customer, hub-side.
func TestRequestReset_DailyCap(t *testing.T) {
	e, _, m := newTestEngine(t)
	if _, err := e.EnsureIssued(cust()); err != nil {
		t.Fatalf("EnsureIssued: %v", err)
	}
	for i := 0; i < 3; i++ {
		if err := e.RequestReset(cust()); err != nil {
			t.Fatalf("RequestReset %d: %v", i+1, err)
		}
	}
	sendsBefore := len(m.sends)
	if err := e.RequestReset(cust()); err == nil {
		t.Fatal("4th reset request of the day should be refused")
	}
	if len(m.sends) != sendsBefore {
		t.Fatal("refused reset must not send an email")
	}
}

// MarkClaimed transitions once: confirmation email exactly once, idempotent afterwards.
func TestMarkClaimed_TransitionOnce(t *testing.T) {
	e, st, m := newTestEngine(t)
	if _, err := e.EnsureIssued(cust()); err != nil {
		t.Fatalf("EnsureIssued: %v", err)
	}
	if err := e.MarkClaimed(cust()); err != nil {
		t.Fatalf("MarkClaimed: %v", err)
	}
	if err := e.MarkClaimed(cust()); err != nil {
		t.Fatalf("MarkClaimed (repeat): %v", err)
	}
	confirms := 0
	for _, s := range m.sends {
		if strings.HasPrefix(s, "claimed:") {
			confirms++
		}
	}
	if confirms != 1 {
		t.Fatalf("claimed-confirmation emails = %d, want exactly 1", confirms)
	}
	cs, _ := st.GetClaim("c1")
	if !cs.Claimed() {
		t.Fatal("not claimed after MarkClaimed")
	}
}

// An email send failure keeps the rotated hash (gate stays armed) and surfaces the error.
func TestIssue_EmailFailureKeepsGateArmed(t *testing.T) {
	e, st, m := newTestEngine(t)
	m.failNext = true
	cs, err := e.EnsureIssued(cust())
	if err == nil {
		t.Fatal("EnsureIssued should surface the send failure")
	}
	if cs == nil || cs.CodeHash == "" {
		t.Fatal("hash must be stored (gate armed) even when the email failed")
	}
	row, _ := st.GetClaim("c1")
	if row == nil || row.EmailedAt != nil {
		t.Fatalf("emailed_at must stay unset on failure: %+v", row)
	}
}

// Reset non-DoS (Scenario C WRONG case): repeated reset REQUESTS never touch the box's password
// or claimed state — the engine only rotates the CODE. The controller's password keeps working
// throughout (the box side owns the password; the hub only mails codes). Red-proof partner: make
// RequestReset also clear claimed_at → this fails.
func TestRequestReset_NeverAltersClaimedState(t *testing.T) {
	e, st, _ := newTestEngine(t)
	if _, err := e.EnsureIssued(cust()); err != nil {
		t.Fatalf("EnsureIssued: %v", err)
	}
	if err := e.MarkClaimed(cust()); err != nil {
		t.Fatalf("MarkClaimed: %v", err)
	}
	claimedBefore, _ := st.GetClaim("c1")
	for i := 0; i < 3; i++ {
		if err := e.RequestReset(cust()); err != nil {
			t.Fatalf("RequestReset %d: %v", i, err)
		}
	}
	claimedAfter, _ := st.GetClaim("c1")
	if !claimedAfter.Claimed() {
		t.Fatal("reset requests un-claimed the box — must never happen")
	}
	if !claimedAfter.ClaimedAt.Equal(*claimedBefore.ClaimedAt) {
		t.Fatal("reset requests moved claimed_at")
	}
}
