package browserrot

import (
	"context"
	"fmt"
	"path/filepath"
	"strings"
	"sync"
	"testing"

	"incredigo/internal/pwgen"
	"incredigo/internal/rotate"
	"incredigo/internal/vault"
)

// healingFake is a browser-free Session+Healer: it FAILS any fill/click/text whose
// selector is not in `good`, and its Heal maps a field description to the correct
// selector — modeling a recipe whose selectors have rotted and a Tier-2 heal fixing them.
// It records every Heal description so a test can prove no secret is ever passed to heal.
type healingFake struct {
	mu        sync.Mutex
	good      map[string]bool // selectors that "exist" on the page
	healMap   map[string]string
	healSeen  []string // descriptions passed to Heal (never a secret, by contract)
	healCalls int
	secrets   [][]byte // secret bytes handed to Fill (for leak assertions)
	success   string
}

func (h *healingFake) Name() string { return "healing-fake" }
func (h *healingFake) Open(context.Context) (Session, error) {
	return h, nil
}
func (h *healingFake) Goto(context.Context, string) (string, error) { return "about:blank", nil }
func (h *healingFake) Fill(_ context.Context, sel string, secret []byte) error {
	h.mu.Lock()
	defer h.mu.Unlock()
	if !h.good[sel] {
		return fmt.Errorf("healing-fake: no element for %q", sel)
	}
	h.secrets = append(h.secrets, append([]byte(nil), secret...))
	return nil
}
func (h *healingFake) Click(_ context.Context, sel string) error {
	h.mu.Lock()
	defer h.mu.Unlock()
	if !h.good[sel] {
		return fmt.Errorf("healing-fake: no element for %q", sel)
	}
	return nil
}
func (h *healingFake) Text(_ context.Context, sel string) (string, error) {
	h.mu.Lock()
	defer h.mu.Unlock()
	if !h.good[sel] {
		return "", fmt.Errorf("healing-fake: no element for %q", sel)
	}
	return h.success, nil
}
func (h *healingFake) Close() error { return nil }

func (h *healingFake) Heal(_ context.Context, description string) (string, error) {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.healCalls++
	h.healSeen = append(h.healSeen, description)
	for kw, sel := range h.healMap {
		if strings.Contains(description, kw) {
			return sel, nil
		}
	}
	return "", fmt.Errorf("healing-fake: cannot heal %q", description)
}

var _ Healer = (*healingFake)(nil)

// The engine must: (1) heal a missed selector via Tier-2, (2) rewrite the recipe in
// memory, (3) never pass a secret to Heal. Then the healed recipe must PERSIST so a
// second run needs NO heal (Tier-1 again) — the whole "learn once" loop.
func TestHeal_engineRetriesRewritesAndPersists(t *testing.T) {
	v := vault.New()
	defer v.Purge()
	const oldPW = "OLD-corr3ct-horse"
	c := gitHubPAT(t, v, oldPW)

	// Recipe with a ROTTED current-password selector; every other selector is correct.
	site := Site{
		Host: "github.com",
		Form: Form{
			CurrentSel: "#stale-current", NewSel: "#new", ConfirmSel: "#confirm",
			SubmitSel: "#submit", SuccessSel: "#result", SuccessText: "SUCCESS",
		},
		Proof: rotate.ProofMockOnly,
	}
	fake := &healingFake{
		good: map[string]bool{
			"#current": true, "#new": true, "#confirm": true, "#submit": true, "#result": true,
		},
		healMap: map[string]string{"current password": "#current"},
		success: "SUCCESS",
	}

	r := New(site, fake, pwgen.DefaultPolicy())
	newH, err := r.Rotate(context.Background(), c, v)
	if err != nil {
		t.Fatalf("Rotate with heal: %v", err)
	}
	if !r.Healed() {
		t.Fatal("expected Healed() == true after a selector miss was healed")
	}
	if got := r.HealedSite().Form.CurrentSel; got != "#current" {
		t.Fatalf("healed recipe CurrentSel = %q, want #current", got)
	}
	if fake.healCalls != 1 {
		t.Fatalf("expected exactly 1 heal call, got %d", fake.healCalls)
	}
	// Leak check: no description handed to Heal may contain either secret.
	newPW := mustReveal(t, v, newH)
	for _, d := range fake.healSeen {
		if strings.Contains(d, oldPW) || strings.Contains(d, newPW) {
			t.Fatalf("LEAK: a secret reached a heal description: %q", d)
		}
	}
	v.Forget(newH)

	// Persist the healed recipe, reload it, and run again with a FRESH healing fake whose
	// heal would fail — proving the second run is Tier-1 (no heal needed).
	path := filepath.Join(t.TempDir(), "recipes.json")
	rb := &RecipeBook{Sites: map[string]Site{}}
	rb.Put(r.HealedSite())
	if err := rb.Save(path); err != nil {
		t.Fatalf("Save recipes: %v", err)
	}
	rb2, err := LoadRecipes(path)
	if err != nil {
		t.Fatalf("Load recipes: %v", err)
	}
	learned, ok := rb2.Get("github.com")
	if !ok {
		t.Fatal("healed recipe not found after reload")
	}
	if learned.Form.CurrentSel != "#current" {
		t.Fatalf("persisted CurrentSel = %q, want #current", learned.Form.CurrentSel)
	}

	fake2 := &healingFake{
		good: map[string]bool{
			"#current": true, "#new": true, "#confirm": true, "#submit": true, "#result": true,
		},
		healMap: map[string]string{}, // heal would FAIL if called
		success: "SUCCESS",
	}
	c2 := gitHubPAT(t, v, oldPW)
	r2 := New(learned, fake2, pwgen.DefaultPolicy())
	newH2, err := r2.Rotate(context.Background(), c2, v)
	if err != nil {
		t.Fatalf("second run (should be Tier-1) failed: %v", err)
	}
	if r2.Healed() {
		t.Fatal("second run should NOT heal — the recipe was already learned")
	}
	if fake2.healCalls != 0 {
		t.Fatalf("second run must need 0 heals, got %d", fake2.healCalls)
	}
	v.Forget(newH2)
}

// If a selector misses and the Session cannot heal (no Healer), the engine fails closed.
func TestHeal_failsClosedWithoutHealer(t *testing.T) {
	v := vault.New()
	defer v.Purge()
	c := gitHubPAT(t, v, "OLD")
	site := testSite()
	site.Form.CurrentSel = "#missing"
	// plain fakeDriver does NOT implement Healer, and its Fill never errors — so to force
	// the miss we use a driver whose Fill fails on the missing selector.
	drv := &fakeDriver{success: "SUCCESS", failFill: "#missing"}
	r := New(site, drv, pwgen.DefaultPolicy())
	if _, err := r.Rotate(context.Background(), c, v); err == nil {
		t.Fatal("expected fail-closed when a selector misses and no healer exists")
	} else if !strings.Contains(err.Error(), "cannot self-heal") {
		t.Fatalf("unexpected error: %v", err)
	}
}

func TestRecipes_roundTripAndMissingFile(t *testing.T) {
	// Missing file -> empty book, no error.
	rb, err := LoadRecipes(filepath.Join(t.TempDir(), "nope.json"))
	if err != nil {
		t.Fatalf("missing file should not error: %v", err)
	}
	if len(rb.Sites) != 0 {
		t.Fatalf("missing file should yield empty book, got %d", len(rb.Sites))
	}

	path := filepath.Join(t.TempDir(), "r.json")
	rb.Put(Site{Host: "a.com", Form: Form{CurrentSel: "#c"}, Proof: rotate.ProofLiveVM})
	rb.Put(Site{Host: "b.com", Form: Form{NewSel: "#n"}})
	if err := rb.Save(path); err != nil {
		t.Fatalf("Save: %v", err)
	}
	got, err := LoadRecipes(path)
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if len(got.Sites) != 2 {
		t.Fatalf("want 2 recipes, got %d", len(got.Sites))
	}
	if s, _ := got.Get("a.com"); s.Form.CurrentSel != "#c" || s.Proof != rotate.ProofLiveVM {
		t.Fatalf("round-trip lost data for a.com: %+v", s)
	}
}

// mustReveal reads a handle's plaintext for a test-only leak assertion.
func mustReveal(t *testing.T, v *vault.Vault, h *vault.Handle) string {
	t.Helper()
	buf, err := v.Open(h)
	if err != nil {
		t.Fatalf("open handle: %v", err)
	}
	return string(buf.Bytes())
}
