"""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


def _solve_captcha_if_present(sb, cfg: Config, *, only_visible: bool = False) -> None:
    """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
    log.info(f"solving via 2captcha: siteKey={info.get('siteKey')} enterprise={info.get('enterprise')}")
    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")),
    )
    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:
    """UC-click the checkbox (up to 3 tries), fall back to 2captcha on image
    puzzles, then click Continue only AFTER the token textarea is populated.
    """
    log.info("challenge page detected")
    hwnd = find_browser_hwnd(sb.driver)

    for attempt in range(1, 4):
        abort.check()
        show_prompt(sb.driver, f"🖱 UC captcha click attempt {attempt}/3…")
        try:
            with isolated_focus(hwnd):
                sb.uc_gui_click_captcha()
            log.info(f"UC captcha click attempt {attempt}")
        except Exception as e:
            log.warning(f"uc_gui_click_captcha {attempt} raised: {e}")
        time.sleep(3.0)
        if _token_populated(sb):
            log.info("token populated after UC click")
            break
    else:
        if not _token_populated(sb):
            log.info("UC click didn't solve; trying 2captcha")
            _solve_captcha_if_present(sb, cfg)

    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 an Oops / error banner is showing."""
    try:
        body = (sb.get_text("body") or "").lower()
    except Exception:
        return None
    for pat in _ERROR_PATTERNS:
        if pat in body:
            return pat
    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
        # The catch-all `spotify.com in url` used to accept any redirect —
        # including /download/windows (a marketing page Spotify bounces to
        # when signup is rejected). Only explicit success URLs above count.
        time.sleep(poll)
    return {"ok": False, "error": "signup timeout"}


def run(sb, params: Dict[str, Any], cfg: Config) -> Dict[str, Any]:
    """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(4.0)
    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:
        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()
