package browserrot

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"sync"
	"testing"
	"time"

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

// ---- fake Driver: records the SEQUENCE of ops and the field LENGTHS, never the
// secret bytes, so tests can assert order + leak-freedom without a browser. -------

type fakeOp struct {
	kind string // "goto" | "fill" | "click" | "text"
	arg  string // url / selector
	n    int    // secret length for fill (never the value)
}

type fakeSession struct {
	mu       *sync.Mutex
	ops      *[]fakeOp
	seen     *[]byte // every secret byte the driver was handed (for leak assertions)
	fields   map[string]string
	success  string // text returned by Text()
	failFill string // selector to error on, if any
}

func (s *fakeSession) Goto(_ context.Context, url string) (string, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	*s.ops = append(*s.ops, fakeOp{kind: "goto", arg: url})
	return url, nil
}
func (s *fakeSession) Fill(_ context.Context, sel string, secret []byte) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if sel == s.failFill {
		return fmt.Errorf("fake: forced fill error on %s", sel)
	}
	*s.ops = append(*s.ops, fakeOp{kind: "fill", arg: sel, n: len(secret)})
	*s.seen = append(*s.seen, secret...)
	s.fields[sel] = string(secret) // the fake may look; the PRODUCTION path may not
	return nil
}
func (s *fakeSession) Click(_ context.Context, sel string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	*s.ops = append(*s.ops, fakeOp{kind: "click", arg: sel})
	return nil
}
func (s *fakeSession) Text(_ context.Context, sel string) (string, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	*s.ops = append(*s.ops, fakeOp{kind: "text", arg: sel})
	return s.success, nil
}
func (s *fakeSession) Close() error { return nil }

type fakeDriver struct {
	mu       sync.Mutex
	ops      []fakeOp
	seen     []byte
	success  string
	failFill string
	fields   map[string]string
}

func (d *fakeDriver) Name() string { return "fake" }
func (d *fakeDriver) Open(context.Context) (Session, error) {
	if d.fields == nil {
		d.fields = map[string]string{}
	}
	return &fakeSession{
		mu: &d.mu, ops: &d.ops, seen: &d.seen, fields: d.fields,
		success: d.success, failFill: d.failFill,
	}, nil
}

// gitHubPAT builds a credential whose derived host is github.com (via the service
// hint), matching a Site{Host: "github.com"}.
func gitHubPAT(t *testing.T, v *vault.Vault, secret string) discover.Credential {
	t.Helper()
	return discover.Credential{
		Source:   "env",
		Identity: ".env / GITHUB_TOKEN",
		Location: "imported/env/GITHUB_TOKEN",
		Secret:   v.Store([]byte(secret)),
		Meta:     map[string]string{discover.MetaService: "github", "username": "octocat"},
	}
}

func testSite() Site {
	return Site{
		Host: "github.com",
		Form: Form{
			CurrentSel: "#current", NewSel: "#new", ConfirmSel: "#confirm",
			SubmitSel: "#submit", SuccessSel: "#result", SuccessText: "SUCCESS",
		},
		Proof: rotate.ProofMockOnly,
	}
}

func TestRotate_spineOrderAndNoLeak(t *testing.T) {
	v := vault.New()
	defer v.Purge()
	const oldPW = "OLD-corr3ct-horse"
	c := gitHubPAT(t, v, oldPW)

	drv := &fakeDriver{success: "SUCCESS"}
	r := New(testSite(), drv, pwgen.DefaultPolicy())

	newH, err := r.Rotate(context.Background(), c, v)
	if err != nil {
		t.Fatalf("Rotate: %v", err)
	}

	// Ops must be: goto → fill current → fill new → fill confirm → click → text.
	want := []string{"goto:https://github.com/settings/tokens", "fill:#current", "fill:#new", "fill:#confirm", "click:#submit", "text:#result"}
	var got []string
	for _, o := range drv.ops {
		got = append(got, o.kind+":"+o.arg)
	}
	if strings.Join(got, ",") != strings.Join(want, ",") {
		t.Fatalf("op order:\n got %v\nwant %v", got, want)
	}

	// The old secret was injected into #current; a fresh (different) secret into #new.
	if drv.fields["#current"] != oldPW {
		t.Fatalf("current field got %q, want old password", drv.fields["#current"])
	}
	if drv.fields["#new"] == oldPW || drv.fields["#new"] == "" {
		t.Fatalf("new field must be a freshly minted, non-empty, different secret")
	}
	if drv.fields["#new"] != drv.fields["#confirm"] {
		t.Fatalf("confirm must equal new")
	}

	// Leak check: the OLD secret must never appear anywhere except as the value we
	// deliberately handed to #current — assert it is not in an unexpected field, and
	// that the Rotator's Name()/error surface never carries a secret.
	if strings.Contains(r.Name(), oldPW) || strings.Contains(r.Name(), drv.fields["#new"]) {
		t.Fatalf("driver name leaked a secret: %q", r.Name())
	}
	v.Forget(newH)
}

func TestRotate_failWhenSiteRejects(t *testing.T) {
	v := vault.New()
	defer v.Purge()
	c := gitHubPAT(t, v, "OLD")

	drv := &fakeDriver{success: "REJECTED: wrong current password"}
	r := New(testSite(), drv, pwgen.DefaultPolicy())

	if _, err := r.Rotate(context.Background(), c, v); err == nil {
		t.Fatal("expected Rotate to fail when the site does not confirm success")
	} else if !strings.Contains(err.Error(), "did not confirm") {
		t.Fatalf("unexpected error: %v", err)
	}
}

func TestVerify_refusesWithoutLoginFlow(t *testing.T) {
	v := vault.New()
	defer v.Purge()
	drv := &fakeDriver{success: "SUCCESS"}
	r := New(testSite(), drv, pwgen.DefaultPolicy()) // Site has no Login flow
	h := v.Store([]byte("whatever"))
	if err := r.Verify(context.Background(), h, v); err == nil {
		t.Fatal("Verify must REFUSE when the site has no browser verify path (hard rule 2)")
	}
}

func TestVerify_loginFlowAuthenticates(t *testing.T) {
	v := vault.New()
	defer v.Purge()
	site := testSite()
	site.Login = Login{
		URL: "https://github.com/login", UserSel: "#u", PassSel: "#p",
		SubmitSel: "#go", SuccessSel: "#welcome", SuccessText: "SUCCESS",
	}
	drv := &fakeDriver{success: "SUCCESS"}
	r := New(site, drv, pwgen.DefaultPolicy())
	r.username = "octocat"
	if err := r.Verify(context.Background(), v.Store([]byte("newpw")), v); err != nil {
		t.Fatalf("Verify should pass when login succeeds: %v", err)
	}
}

// The Rotator must satisfy the shared contract AND flow through the proof gate: a
// MOCK-ONLY site is skipped by Execute unless the operator opts in.
func TestProofGate_mockOnlySkippedByDefault(t *testing.T) {
	r := New(testSite(), &fakeDriver{}, pwgen.DefaultPolicy())
	if got := r.Proof(); got != rotate.ProofMockOnly {
		t.Fatalf("proof: got %v want MOCK-ONLY", got)
	}
	if r.Proof() >= rotate.MinExecuteProof {
		t.Fatal("a MOCK-ONLY browser site must be below the execute gate")
	}
}

// ---- LIVE-VM integration: real Chromium against a real local form. -------------
// Skipped unless CHROME_BIN points at a usable Chromium (set by lab-provision-browserrot.sh).

func TestIntegration_realBrowserRotation(t *testing.T) {
	bin := os.Getenv("CHROME_BIN")
	if bin == "" {
		t.Skip("set CHROME_BIN to a Chromium executable to run the LIVE-VM browser proof")
	}
	runLiveRotationProof(t, &RodDriver{Bin: bin, Headless: true, Timeout: 30 * time.Second})
}

// runLiveRotationProof drives one Driver through the full LIVE-VM proof: it stands up a
// throwaway change-password + login website in-process, then asserts
// discover→inject→submit→verify(new)→old-rejected. Both the go-rod and node-sidecar
// drivers run this identical harness, so "parity" is a literal shared test. Optional
// mutators tweak the Site before the run (e.g. to rot a selector for a self-heal proof).
// It returns the Rotator so callers can assert post-run state (Healed(), HealedSite()).
func runLiveRotationProof(t *testing.T, drv Driver, mutators ...func(*Site)) *Rotator {
	t.Helper()

	const oldPW = "OLD-corr3ct-horse-battery"
	var mu sync.Mutex
	stored := oldPW
	mux := http.NewServeMux()
	mux.HandleFunc("/.well-known/change-password", func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, "/account/security/password", http.StatusSeeOther)
	})
	mux.HandleFunc("/account/security/password", func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost {
			r.ParseForm()
			mu.Lock()
			ok := r.FormValue("current") == stored
			if ok {
				stored = r.FormValue("new")
			}
			mu.Unlock()
			if !ok {
				w.WriteHeader(http.StatusForbidden)
				fmt.Fprint(w, `<h1 id="result">REJECTED</h1>`)
				return
			}
			fmt.Fprint(w, `<h1 id="result">SUCCESS</h1>`)
			return
		}
		fmt.Fprint(w, `<!doctype html><meta charset=utf-8><form method=POST>
		  <input id=current name=current type=password>
		  <input id=new name=new type=password>
		  <button id=submit type=submit>Change</button></form>`)
	})
	// login-verify endpoint
	mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost {
			r.ParseForm()
			mu.Lock()
			ok := r.FormValue("p") == stored
			mu.Unlock()
			if ok {
				fmt.Fprint(w, `<h1 id="welcome">SUCCESS</h1>`)
			} else {
				fmt.Fprint(w, `<h1 id="welcome">DENIED</h1>`)
			}
			return
		}
		fmt.Fprint(w, `<!doctype html><meta charset=utf-8><form method=POST>
		  <input id=p name=p type=password><button id=go type=submit>Login</button></form>`)
	})
	srv := httptest.NewServer(mux)
	defer srv.Close()

	host := strings.TrimPrefix(srv.URL, "http://") // 127.0.0.1:PORT

	site := Site{
		Host:      host,
		ChangeURL: srv.URL + "/.well-known/change-password", // exercise RFC 8615 redirect
		Form: Form{
			CurrentSel: "#current", NewSel: "#new",
			SubmitSel: "#submit", SuccessSel: "#result", SuccessText: "SUCCESS",
		},
		Login: Login{
			URL: srv.URL + "/login", PassSel: "#p", SubmitSel: "#go",
			SuccessSel: "#welcome", SuccessText: "SUCCESS",
		},
		Proof: rotate.ProofLiveVM,
	}
	for _, m := range mutators {
		m(&site)
	}

	v := vault.New()
	defer v.Purge()
	c := discover.Credential{
		Source: "file", Identity: host + " / cred", Location: "imported/file/x",
		Secret: v.Store([]byte(oldPW)),
		// host has no curated entry, so links.For falls back to well-known on this host.
		Meta: map[string]string{},
	}
	// Point HostFor at our ephemeral host via the git-style "user @ host" identity.
	c.Identity = "acct @ " + host

	r := New(site, drv, pwgen.DefaultPolicy())

	if !r.Detect(c) {
		t.Fatalf("Detect should match host %q (HostFor=%q)", host, hostForDebug(c))
	}

	ctx := context.Background()
	newH, err := r.Rotate(ctx, c, v)
	if err != nil {
		t.Fatalf("Rotate against real browser: %v", err)
	}
	if err := r.Verify(ctx, newH, v); err != nil {
		t.Fatalf("Verify (new pw must authenticate): %v", err)
	}
	// old must no longer authenticate — prove the rotation was real, not a no-op.
	mu.Lock()
	oldStillWorks := stored == oldPW
	mu.Unlock()
	if oldStillWorks {
		t.Fatal("old password still stored — rotation did not take effect")
	}
	if err := r.RevokeOld(ctx, c, v); err != nil {
		t.Fatalf("RevokeOld: %v", err)
	}
	t.Log("LIVE-VM browser rotation proof passed: discover→inject→submit→verify(new)→old-rejected")
	return r
}

func hostForDebug(c discover.Credential) string { return links.HostFor(c) }
