"""Spotify signup wizard driver (SB-native helpers only — UC-mode safe).

Uses `sb.wait_for_element_present` / `sb.update_text` / `sb.click` directly
rather than JS execute_script. UC mode's driver doesn't honour vanilla
.execute_script reliably; native SB methods route through UC's safe path.

Wizard pages (URL hash or DOM heading tells us which):
  step 0  email           → Next (captcha may appear)
  step 1  password        → Next
  step 2  displayName + DOB + gender → Next
  step 3  T&C             → Sign up

After step 3 Spotify may redirect through challenge.spotify.com with a
reCAPTCHA v2 Enterprise checkbox widget. We use `sb.uc_gui_click_captcha()`
which moves the real OS mouse to the checkbox and clicks. If images pop,
2captcha solves + we inject the token.
"""
import time
from typing import Any, Dict, Optional

import abort
from captcha import detect as detect_captcha, inject_token, solve_recaptcha_v2
from config import Config
from utils.fill import hide_prompt, show_prompt
from utils.focus import find_browser_hwnd, isolated_focus
from utils.logger import child

log = child("spotify_signup")


SIGNUP_URL = "https://www.spotify.com/us/signup"

EMAIL_SELECTORS = [
    'input[type="email"]',
    'input#email',
    'input[name="email"]',
    'input[data-testid="email"]',
    'input[autocomplete="email"]',
]
PASSWORD_SELECTORS = [
    'input[type="password"]',
    'input#password',
    'input[name="password"]',
    'input[data-testid="password"]',
]


def _first_visible(sb, selectors, timeout_s: float = 15.0) -> Optional[str]:
    """Return first selector from list that becomes visible before timeout."""
    deadline = time.monotonic() + timeout_s
    while time.monotonic() < deadline:
        abort.check()
        for s in selectors:
            try:
                if sb.is_element_visible(s):
                    return s
            except Exception:
                pass
        time.sleep(0.25)
    return None


def _current_step(sb) -> int:
    try:
        url = sb.get_current_url() or ""
        import re
        m = re.search(r"step=(\d+)", url)
        if m:
            return int(m.group(1))
    except Exception:
        pass
    try:
        txt = (sb.get_text("h1") or "").lower()
    except Exception:
        txt = ""
    if "create a password" in txt:
        return 1
    if "tell us" in txt or "what's your" in txt:
        return 2
    if "terms" in txt and ("conditions" in txt or "service" in txt):
        return 3
    return 0


def _click_submit(sb) -> bool:
    for label in ("Next", "Sign up", "Continue"):
        try:
            if sb.is_text_visible(label, selector="button") or sb.is_text_visible(label):
                sb.click(f'button:contains("{label}")')
                return True
        except Exception:
            continue
    for sel in (
        'button[type="submit"]:not([disabled])',
        'button[data-testid="submit"]:not([disabled])',
        'button[data-encore-id="buttonPrimary"]:not([disabled])',
    ):
        try:
            if sb.is_element_visible(sel):
                sb.click(sel)
                return True
        except Exception:
            continue
    return False


_ACTION_SNIFFER_JS = r"""
(function() {
  if (window.__ff_action_hook_installed) return;
  window.__ff_action_hook_installed = true;
  window.__ff_recaptcha_action = null;
  function hook() {
    if (!window.grecaptcha) return false;
    var ent = window.grecaptcha.enterprise;
    var target = ent || window.grecaptcha;
    if (!target || !target.execute) return false;
    if (target.__ff_wrapped) return true;
    var orig = target.execute;
    target.execute = function(siteKey, opts) {
      try {
        if (opts && opts.action) window.__ff_recaptcha_action = opts.action;
      } catch (e) {}
      return orig.apply(this, arguments);
    };
    target.__ff_wrapped = true;
    return true;
  }
  if (!hook()) {
    var iv = setInterval(function() { if (hook()) clearInterval(iv); }, 250);
    setTimeout(function() { clearInterval(iv); }, 30000);
  }
})();
"""


_NETWORK_SNIFFER_JS = r"""
(function() {
  if (window.__ff_net_hook_installed) return;
  window.__ff_net_hook_installed = true;
  window.__ff_captured = [];
  function capture(url, status, body, phase) {
    try {
      if (!url) return;
      if (url.indexOf('spotify') === -1) return;
      // Focus on validate / account / signup / email-dossier / challenge endpoints
      if (!/validate|account|signup|email-dossier|challenge|dossier/i.test(url)) return;
      window.__ff_captured.push({
        t: Date.now(), phase: phase, url: url.slice(0, 200),
        status: status, body: (body || '').slice(0, 400)
      });
      if (window.__ff_captured.length > 40) window.__ff_captured.shift();
    } catch (e) {}
  }
  var origFetch = window.fetch;
  window.fetch = function(url, opts) {
    var u = typeof url === 'string' ? url : (url && url.url);
    var p = origFetch.apply(this, arguments);
    p.then(function(r) {
      try {
        var c = r.clone();
        c.text().then(function(b) { capture(u, r.status, b, 'fetch'); }).catch(function(){});
      } catch (e) {}
      return r;
    }).catch(function() {});
    return p;
  };
  var origOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function(m, u) { this.__ff_url = u; return origOpen.apply(this, arguments); };
  var origSend = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.send = function() {
    var xhr = this;
    xhr.addEventListener('loadend', function() {
      // responseText is only safe when responseType is '' or 'text'.
      // Spotify uses responseType='json' on many XHRs — reading
      // responseText there throws InvalidStateError and breaks THEIR
      // error-handling code (which was making us look rate-limited).
      var body = '';
      try {
        if (xhr.responseType === '' || xhr.responseType === 'text') {
          body = xhr.responseText || '';
        } else if (xhr.responseType === 'json' && xhr.response) {
          try { body = JSON.stringify(xhr.response); } catch (e) {}
        }
      } catch (e) {}
      capture(xhr.__ff_url, xhr.status, body, 'xhr');
    });
    return origSend.apply(this, arguments);
  };
})();
"""


def _install_network_sniffer(sb) -> None:
    try:
        sb.execute_script(_NETWORK_SNIFFER_JS)
    except Exception as e:
        log.debug(f"network sniffer install failed: {e}")


def _dump_network_captures(sb) -> None:
    try:
        caps = sb.execute_script("return window.__ff_captured || [];")
        if not caps:
            log.info("network captures: (none)")
            return
        for c in caps[-15:]:  # last 15 events
            log.info(f"net[{c.get('phase')}]: {c.get('status')} {c.get('url')} :: {c.get('body')}")
    except Exception as e:
        log.warning(f"dump captures failed: {e}")


def _install_action_sniffer(sb) -> None:
    """Hook grecaptcha(.enterprise).execute so we can read the `action`
    parameter the page passes — needed for 2captcha's enterprisePayload so
    the returned token matches what Spotify's backend expects.
    """
    try:
        sb.execute_script(_ACTION_SNIFFER_JS)
    except Exception as e:
        log.debug(f"action sniffer install failed (non-fatal): {e}")


def _get_sniffed_action(sb) -> Optional[str]:
    try:
        return sb.execute_script("return window.__ff_recaptcha_action || null;")
    except Exception:
        return None


def _current_ua(sb) -> Optional[str]:
    try:
        return sb.execute_script("return navigator.userAgent || '';")
    except Exception:
        return None


def _solve_captcha_if_present(sb, cfg: Config, *, only_visible: bool = False) -> None:
    # 2captcha Chrome extension is loaded via extension_dir and handles
    # captcha detection + solving + form-auto-submit. Our API-based token
    # injection conflicted with Spotify's token-provenance check. When the
    # extension is configured (cfg.extension_dir set), skip API solve
    # entirely — let the extension do it from inside the browser's own
    # session. Falls through when the extension isn't configured.
    if cfg.extension_dir:
        # Small delay so the extension has time to pick up the captcha
        import time as _t
        _t.sleep(3)
        return

    """Run 2captcha if a captcha is detected. Retries for up to 10s so the
    reCAPTCHA iframe has time to load (matters over slow proxies).

    Spotify's wizard pages embed an INVISIBLE reCAPTCHA v2 Enterprise.
    Without the token the form submit is silently rejected.
    """
    info = None
    # Over slow proxies the reCAPTCHA <script> can take 10-25s to inject
    # its iframes. Retry aggressively until we see one.
    deadline = time.monotonic() + 30
    last_log = 0
    while time.monotonic() < deadline:
        try:
            info = detect_captcha(sb)
        except Exception as e:
            log.warning(f"detect_captcha raised: {e}")
            info = None
        if info:
            break
        if time.monotonic() - last_log > 5:
            log.info(f"waiting for reCAPTCHA iframe to load… (elapsed {int(time.monotonic() - (deadline - 30))}s)")
            last_log = time.monotonic()
        time.sleep(1.0)
    if not info:
        log.warning("no captcha detected after 30s — Next will likely be rejected")
        return
    if only_visible and info.get("invisible"):
        log.info("invisible captcha present; skipping 2captcha (Google auto-scores)")
        return
    if not cfg.captcha2_key:
        log.warning("captcha detected but no captcha2_key configured")
        return
    # Enrich 2captcha call with UA + proxy. On action: use "signup" always —
    # it's what 2captcha's solver pool handles fast (~11-20s) and empirically
    # passes Spotify's backend validation. The sniffed action (e.g.
    # "website/signup/submit_email") is logged for diagnostics but NOT sent
    # to 2captcha, because non-common actions time their solver out.
    sniffed = _get_sniffed_action(sb)
    if sniffed:
        log.info(f"page action sniffed: {sniffed} (using 'signup' for 2captcha)")
    action = "signup"
    ua = _current_ua(sb)
    log.info(f"solving via 2captcha: siteKey={info.get('siteKey')} enterprise={info.get('enterprise')} action={action}")
    show_prompt(sb.driver, "🔐 2captcha solving… (30-90s)")
    r = solve_recaptcha_v2(
        cfg.captcha2_key,
        sb.get_current_url(),
        info["siteKey"],
        enterprise=bool(info.get("enterprise")),
        invisible=bool(info.get("invisible")),
        user_agent=ua,
        action=action,
        proxy=cfg.proxy,
    )
    if r.get("ok"):
        inject_token(sb, r["token"])
        log.info("captcha token injected")
        show_prompt(sb.driver, "✅ token injected — submitting")
        time.sleep(0.8)
    else:
        log.warning(f"2captcha failed: {r.get('error')}")
        show_prompt(sb.driver, f"❌ 2captcha failed: {r.get('error')}")


def _fill_email(sb, email: str) -> Optional[str]:
    sel = _first_visible(sb, EMAIL_SELECTORS, timeout_s=20)
    if not sel:
        return "email field not found"
    log.info(f"email: using selector {sel}")
    sb.update_text(sel, email)
    return None


def _fill_password(sb, password: str) -> Optional[str]:
    sel = _first_visible(sb, PASSWORD_SELECTORS, timeout_s=15)
    if not sel:
        return "password field not found"
    log.info(f"password: using selector {sel}")
    sb.update_text(sel, password)
    return None


def _fill_profile(sb, display_name: str, dob: Dict[str, Any], gender: str) -> None:
    name_sels = ['input#displayName', 'input[name="displayName"]']
    sel = _first_visible(sb, name_sels, timeout_s=15)
    if sel:
        sb.update_text(sel, display_name)
        log.info("displayName filled")
    if dob:
        for key, sels in (
            ("day", ['input#day', 'input[name="day"]']),
            ("year", ['input#year', 'input[name="year"]']),
        ):
            if dob.get(key) is not None:
                s = _first_visible(sb, sels, timeout_s=3)
                if s:
                    sb.update_text(s, str(dob[key]))
        if dob.get("month") is not None:
            m = int(dob["month"])
            # UC mode's CDP execute_script does NOT supply `arguments` the way
            # vanilla Selenium does — passing args throws ReferenceError. Bake
            # the value directly into the script instead.
            js = (
                "(function() {"
                f"  const mValue = {m};"
                "  const sel = document.querySelector('select#month, select[name=\"month\"], select[data-testid=\"birthDateMonth\"]');"
                "  if (!sel) return { ok: false, error: 'month select not found' };"
                "  const names = ['','January','February','March','April','May','June','July','August','September','October','November','December'];"
                "  const candidates = [String(mValue), String(mValue).padStart(2,'0'), names[mValue]];"
                "  for (const opt of sel.options) {"
                "    if (candidates.includes(opt.value) || candidates.includes(opt.text)) {"
                "      sel.value = opt.value;"
                "      sel.dispatchEvent(new Event('change', { bubbles: true }));"
                "      sel.dispatchEvent(new Event('input',  { bubbles: true }));"
                "      sel.dispatchEvent(new Event('blur',   { bubbles: true }));"
                "      return { ok: true, value: opt.value, text: opt.text };"
                "    }"
                "  }"
                "  return { ok: false, error: 'no option matched' };"
                "})();"
            )
            try:
                res = sb.execute_script(js)
                if res and res.get("ok"):
                    log.info(f"month set: value={res.get('value')} text={res.get('text')}")
                else:
                    log.warning(f"month set failed: {res}")
            except Exception as e:
                log.warning(f"month select JS raised: {e}")
    g = (gender or "Prefer not to say").lower()
    # Spotify only has 4 radios: male, female, other (Something else),
    # prefer_not_to_say. Non-binary and similar map to "other".
    want_map = {
        "male": ["male", "man"],
        "female": ["female", "woman"],
        "non-binary": ["other", "something else"],
        "nonbinary": ["other", "something else"],
        "other": ["other", "something else"],
        "prefer not to say": ["prefer_not_to_say", "prefer-not-to-say", "prefer not to say"],
    }
    wants = want_map.get(g, ["other"])  # default to "other" for any unknown gender
    import json as _json
    # Spotify's gender radios have visually-hidden inputs + labels that
    # carry the click handler. Clicking the input directly misses React;
    # clicking the label triggers the native radio group behaviour.
    js = (
        "(function() {"
        f"  var wants = {_json.dumps(wants)};"
        "  var radios = document.querySelectorAll('input[type=\"radio\"][name=\"gender\"]');"
        "  for (var i = 0; i < radios.length; i++) {"
        "    var r = radios[i];"
        "    var val = (r.value || '').toLowerCase();"
        "    var id = (r.id || '').toLowerCase();"
        "    var matches = wants.some(function(w) {"
        "      var lw = w.toLowerCase();"
        "      return val === lw || id === lw || val.indexOf(lw) !== -1 || id.indexOf(lw) !== -1;"
        "    });"
        "    if (!matches) continue;"
        "    var label = r.id ? document.querySelector('label[for=\"' + CSS.escape(r.id) + '\"]') : null;"
        "    try { (label || r).click(); } catch(e) {}"
        "    if (!r.checked) { r.checked = true; r.dispatchEvent(new Event('change', { bubbles: true })); r.dispatchEvent(new Event('input', { bubbles: true })); }"
        "    return { ok: true, value: r.value, id: r.id };"
        "  }"
        "  return { ok: false, error: 'no radio matched', wants: wants };"
        "})();"
    )
    try:
        res = sb.execute_script(js)
        if res and res.get("ok"):
            log.info(f"gender: {res.get('value') or res.get('id')}")
        else:
            log.warning(f"gender set failed: {res}")
    except Exception as e:
        log.warning(f"gender JS raised: {e}")


def _is_on_challenge(sb) -> bool:
    try:
        url = sb.get_current_url() or ""
        if "challenge.spotify.com" in url or "/signup/challenge" in url:
            return True
    except Exception:
        pass
    try:
        body = sb.get_text("body") or ""
        if "we need to make sure" in body.lower():
            return True
    except Exception:
        pass
    return False


def _token_populated(sb) -> bool:
    try:
        return bool(sb.execute_script(
            "var t=document.querySelector('textarea[name=\"g-recaptcha-response\"], #g-recaptcha-response');"
            "return !!(t && t.value && t.value.length > 50);"
        ))
    except Exception:
        return False


def _handle_challenge(sb, cfg: Config) -> bool:
    """Let the 2captcha extension solve. Falls back to UC + API if extension
    isn't configured.
    """
    log.info("challenge page detected")
    hwnd = find_browser_hwnd(sb.driver)

    if False:  # Extension path disabled — UC mode strips extension flags.
        pass
    else:
        # Wait ~5 min for user to solve manually. The banner tells them.
        show_prompt(sb.driver, "👆 Solve the captcha — rest runs itself")
        log.info("waiting up to 5 min for user to solve captcha manually")
        deadline = time.monotonic() + 300
        while time.monotonic() < deadline:
            abort.check()
            if _token_populated(sb):
                log.info("user solved captcha (token populated)")
                break
            if not _is_on_challenge(sb):
                log.info("challenge cleared (user navigated past)")
                hide_prompt(sb.driver)
                return True
            time.sleep(2.0)
        # UC + 2captcha-API fallbacks intentionally disabled — user solves
        # on challenge page, automation resumes when they navigate past.

    deadline = time.monotonic() + 120
    while time.monotonic() < deadline and not _token_populated(sb):
        abort.check()
        time.sleep(1.0)

    if not _token_populated(sb):
        log.warning("captcha token never populated; giving up on challenge")
        show_prompt(sb.driver, "❌ captcha couldn't be solved automatically")
        return False

    log.info("token populated; clicking Continue")
    show_prompt(sb.driver, "✅ captcha solved — clicking Continue")
    deadline = time.monotonic() + 60
    while time.monotonic() < deadline:
        abort.check()
        if not _is_on_challenge(sb):
            log.info("challenge cleared")
            hide_prompt(sb.driver)
            return True
        for sel in ('button[name="solve"]:not([disabled])',
                    'button[type="submit"]:not([disabled])',
                    'button[data-encore-id="buttonPrimary"]:not([disabled])'):
            try:
                if sb.is_element_visible(sel):
                    sb.click(sel)
                    break
            except Exception:
                continue
        time.sleep(1.5)
    return False


_ERROR_PATTERNS = [
    "oops! something went wrong",
    "something went wrong",
    "sorry, we had trouble",
    "email address already",
    "couldn't create your account",
]


def _spotify_error_visible(sb) -> Optional[str]:
    """Return the first error string if a VISIBLE Spotify error banner is
    showing. Scope to visible elements only — using body.innerText picks up
    script tags, preloaded strings, and whatever else lives in the DOM,
    causing false positives that kill good runs.
    """
    js = r"""
    (function() {
      var patterns = ['oops! something went wrong', 'something went wrong',
                      'sorry, we had trouble', 'email address already',
                      "couldn't create your account"];
      function visible(n) {
        if (!n) return false;
        var r = n.getBoundingClientRect();
        var s = window.getComputedStyle(n);
        if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false;
        return !(r.width === 0 && r.height === 0);
      }
      // Spotify error banner is usually role=alert or data-encore-id="banner"
      // with error styling. Look at likely error-containing elements.
      var candidates = document.querySelectorAll(
        '[role="alert"], [data-encore-id*="error"], [data-encore-id*="banner"],' +
        ' [class*="error" i], [class*="Error"]'
      );
      for (var i = 0; i < candidates.length; i++) {
        var el = candidates[i];
        if (!visible(el)) continue;
        var t = (el.innerText || '').toLowerCase().trim();
        if (!t) continue;
        for (var j = 0; j < patterns.length; j++) {
          if (t.indexOf(patterns[j]) !== -1) {
            return { pat: patterns[j], text: t.slice(0, 120), tag: el.tagName, cls: el.className };
          }
        }
      }
      return null;
    })();
    """
    try:
        res = sb.execute_script(js)
        if res:
            log.info(f"error banner found: {res}")
            return res.get("pat")
    except Exception as e:
        log.debug(f"error check failed: {e}")
    return None


def _wait_for_signup_complete(sb, cfg: Config) -> Dict[str, Any]:
    deadline = time.monotonic() + cfg.signup_timeout_seconds
    poll = max(1, cfg.heartbeat_url_poll_seconds)
    import re
    re_ok_open = re.compile(r"^https://open\.spotify\.com")
    re_ok_www = re.compile(r"^https://www\.spotify\.com/(\w{2}(-\w{2})?/?)?(account|home|premium|\?|$)")
    re_pending = re.compile(r"spotify\.com/(us/)?(signup|login)|challenge\.spotify\.com")
    while time.monotonic() < deadline:
        abort.check()
        try:
            url = sb.get_current_url() or ""
        except Exception as e:
            return {"ok": False, "error": f"driver lost: {e}"}
        if re_ok_open.match(url) or re_ok_www.match(url):
            return {"ok": True, "final_url": url}
        # Check for explicit Spotify error banner on any signup page — this
        # used to silently pass because we saw the URL as still "on signup"
        # and eventually timed out, OR matched the catch-all spotify.com path.
        err = _spotify_error_visible(sb)
        if err:
            return {"ok": False, "error": f"spotify rejected signup: {err}"}
        if re_pending.search(url):
            if _is_on_challenge(sb):
                if _handle_challenge(sb, cfg):
                    continue
            time.sleep(poll)
            continue
        # Any other spotify.com URL (e.g. /download/windows after signup) means
        # we're past the signup flow and logged in. The error-banner check
        # above already rejected false-successes (Oops banner stays on signup
        # page with pending URL), so reaching here is a real success.
        if "spotify.com" in url:
            return {"ok": True, "final_url": url}
        time.sleep(poll)
    return {"ok": False, "error": "signup timeout"}


_EXT_ID = "ifibfemgeogfhoebkmokieepdoobkbpo"  # 2captcha reCAPTCHA Solver (deterministic via manifest "key")


def _ensure_2captcha_ext(sb, cfg: Config) -> None:
    """Seed the 2captcha extension's API key if the popup shows the login form.

    Extension storage persists in the user_data_dir, so after first launch
    this is a fast no-op. The extension auto-solves reCAPTCHAs from inside
    the browser — matching the widget-generated token path Spotify expects.
    """
    if not cfg.captcha2_key or not cfg.extension_dir:
        return
    import json as _json
    popup_url = f"chrome-extension://{_EXT_ID}/popup/popup.html"
    try:
        sb.open(popup_url)
        sb.sleep(2)
        needs_key = sb.execute_script(
            "return (function(){"
            "  var f = document.getElementById('login-form');"
            "  return !!(f && f.style.display !== 'none' && document.querySelector('input[name=\"apiKey\"]'));"
            "})();"
        )
        if not needs_key:
            log.info("2captcha extension already configured")
            return
        log.info("seeding 2captcha extension with API key")
        sb.execute_script(
            "(function(){"
            "  var inp = document.querySelector('input[name=\"apiKey\"]');"
            f"  inp.value = {_json.dumps(cfg.captcha2_key)};"
            "  inp.dispatchEvent(new Event('input', {bubbles: true}));"
            "  inp.dispatchEvent(new Event('change', {bubbles: true}));"
            "  var btn = document.querySelector('#login-form button');"
            "  if (btn) btn.click();"
            "})();"
        )
        sb.sleep(3)
        logged_in = sb.execute_script(
            "return (function(){"
            "  var f = document.getElementById('login-form');"
            "  return !(f && f.style.display !== 'none');"
            "})();"
        )
        if logged_in:
            log.info("2captcha extension: API key accepted")
        else:
            log.warning("2captcha extension: login form still visible after submit")
    except Exception as e:
        log.warning(f"2captcha ext config raised: {e}")


def _clear_spotify_session(sb) -> None:
    """Delete Spotify cookies before signup so a prior run's session doesn't
    redirect us away from the signup form. Persistent profile dir retains
    the extension but we wipe site state between flows.
    """
    try:
        # Visit spotify.com so we have cookies to delete, then wipe via CDP.
        sb.open("https://www.spotify.com/us/")
        sb.sleep(1)
        sb.driver.execute_cdp_cmd(
            "Network.clearBrowserCookies", {}
        )
        log.info("cleared spotify session cookies")
    except Exception as e:
        log.warning(f"cookie clear failed (non-fatal): {e}")


def run(sb, params: Dict[str, Any], cfg: Config) -> Dict[str, Any]:
    # Extension doesn't actually load under UC mode — accept manual captcha
    # on challenge page. Incognito (configured in daemon factory) keeps each
    # run clean so no session-clear needed.
    """Drive the Spotify signup wizard; return when open.spotify.com is reached."""
    email = params["email"]
    password = params["password"]
    display_name = params.get("displayName") or params.get("display_name") or "Sam"
    dob = params.get("dob") or {"day": 14, "month": 6, "year": 1998}
    gender = params.get("gender") or "Prefer not to say"

    log.info(f"signup: opening {SIGNUP_URL}")
    sb.open(SIGNUP_URL)
    sb.sleep(1.0)
    # Install the grecaptcha.enterprise.execute sniffer ASAP so we capture
    # the `action` string the page will pass. The hook polls for grecaptcha
    # up to 30s after install, so it catches late-loaded cases too.
    _install_action_sniffer(sb)
    _install_network_sniffer(sb)
    sb.sleep(3.0)
    # Log what IP/UA Chrome itself sees — this is what Spotify will fingerprint.
    try:
        ip_ua = sb.execute_script(
            "return (async function(){ try {"
            "  const r = await fetch('https://ipinfo.io/json');"
            "  const j = await r.json();"
            "  return { ip: j.ip, country: j.country, org: j.org, ua: navigator.userAgent };"
            "} catch(e) { return { err: String(e) }; } })();"
        )
        log.info(f"chrome-pov ip/ua: {ip_ua}")
    except Exception as e:
        log.warning(f"ip probe failed: {e}")
    try:
        diag = sb.execute_script(
            "return (function(){ return {"
            " url: location.href, title: document.title,"
            " bodyLen: (document.body && document.body.innerText || '').length,"
            " scripts: document.querySelectorAll('script[src*=\"recaptcha\"]').length,"
            " iframes: document.querySelectorAll('iframe').length,"
            " ready: document.readyState"
            " }; })();"
        )
        log.info(f"page after open: {diag}")
    except Exception as e:
        log.warning(f"page diag failed: {e}")

    # Step 0: email
    if _current_step(sb) <= 0:
        err = _fill_email(sb, email)
        if err:
            return {"ok": False, "error": err}
        sb.sleep(0.2)
        _solve_captcha_if_present(sb, cfg)
        if not _click_submit(sb):
            return {"ok": False, "error": "email Next click failed"}
        log.info("email page: Next clicked")

    # Step 1: password. If we're still at step 0, spotify rejected the
    # email submit (usually invalid captcha score or email blocked) — bail
    # rather than blindly continuing.
    sb.sleep(1.5)
    err = _spotify_error_visible(sb)
    if err:
        _dump_network_captures(sb)
        return {"ok": False, "error": f"spotify rejected at email: {err}"}
    if _current_step(sb) <= 1:
        err = _fill_password(sb, password)
        if err:
            return {"ok": False, "error": err}
        sb.sleep(0.2)
        _solve_captcha_if_present(sb, cfg)
        if not _click_submit(sb):
            return {"ok": False, "error": "password Next click failed"}
        log.info("password page: Next clicked")

    # Step 2: profile. Same guard — if we're still on an earlier step with
    # an error banner, bail early.
    sb.sleep(1.5)
    err = _spotify_error_visible(sb)
    if err:
        return {"ok": False, "error": f"spotify rejected at password: {err}"}
    if _current_step(sb) <= 2:
        _fill_profile(sb, display_name, dob, gender)
        sb.sleep(0.2)
        _solve_captcha_if_present(sb, cfg)
        if not _click_submit(sb):
            return {"ok": False, "error": "profile Next click failed"}
        log.info("profile page: Next clicked")

    # Step 3: T&C → Sign up
    sb.sleep(1.5)
    err = _spotify_error_visible(sb)
    if err:
        return {"ok": False, "error": f"spotify rejected at profile: {err}"}
    _solve_captcha_if_present(sb, cfg)
    _click_submit(sb)
    log.info("T&C page: Sign up clicked")

    result = _wait_for_signup_complete(sb, cfg)
    if not result.get("ok"):
        return {"ok": False, "error": result.get("error") or "signup failed"}

    return {
        "ok": True,
        "final_url": result["final_url"],
        "email": email,
        "password": password,
    }


def run_standalone(sb_factory, params: Dict[str, Any], cfg: Config) -> Dict[str, Any]:
    """Entry point for `ext_spotify_signup` command (no trikatuka/smurfmarkt)."""
    with sb_factory() as sb:
        abort.register_sb(sb)
        try:
            return run(sb, params, cfg)
        finally:
            abort.clear_sb()
