"""Interactive browser authentication for OIDC providers.

Opens a headed Chromium browser for the user to complete an OIDC login
flow, mints a temporary Connect API key via the UI, saves the browser
storage state, then closes the browser before tests start.

.. warning::

    The UI automation in ``_create_api_key_via_ui`` is inherently fragile
    and may break across Connect versions.  If Connect gains a programmatic
    endpoint for temporary key creation, this should be replaced.
"""

from __future__ import annotations

import os
import shutil
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path

from playwright.sync_api import Page, sync_playwright

# Prefix for VIP-managed API keys.  A timestamp is appended per run.
_KEY_NAME_PREFIX = "_vip_interactive_"


@dataclass
class InteractiveAuthSession:
    """Result of an interactive OIDC authentication flow.

    Holds the saved browser storage state (for Playwright tests) and a
    minted Connect API key (for httpx API tests).  Call ``cleanup()``
    after the test session to delete the temporary API key.
    """

    storage_state_path: Path
    api_key: str | None = None
    key_name: str = ""

    _connect_url: str = field(default="", repr=False)
    _tmpdir: str = field(default="", repr=False)

    def cleanup(self) -> None:
        """Delete the minted API key and remove the temp directory."""
        if self.api_key and self._connect_url:
            try:
                _delete_api_key(self._connect_url, self.api_key, self.key_name)
            except Exception as exc:
                print(f">>> Warning: Could not delete API key: {exc}")

        if self._tmpdir and os.path.isdir(self._tmpdir):
            shutil.rmtree(self._tmpdir, ignore_errors=True)


def _load_cached_auth(cache_path: Path) -> InteractiveAuthSession | None:
    """Load a cached auth session if the storage state file exists and is recent."""
    if not cache_path.exists():
        return None

    import json

    # Check if the cache is less than 4 hours old.
    age = time.time() - cache_path.stat().st_mtime
    if age > 4 * 3600:
        return None

    # Read the companion metadata file if it exists.
    meta_path = cache_path.with_suffix(".meta.json")
    api_key = None
    key_name = ""
    connect_url = ""
    if meta_path.exists():
        try:
            meta = json.loads(meta_path.read_text())
            api_key = meta.get("api_key")
            key_name = meta.get("key_name", "")
            connect_url = meta.get("connect_url", "")
        except Exception:
            pass

    print(f">>> Reusing cached auth session from {cache_path}")
    return InteractiveAuthSession(
        storage_state_path=cache_path,
        api_key=api_key,
        key_name=key_name,
        _connect_url=connect_url,
        _tmpdir="",
    )


def _save_auth_cache(session: InteractiveAuthSession, cache_path: Path) -> None:
    """Save auth session metadata alongside the storage state."""
    import json
    import shutil as _shutil

    # Copy storage state to the cache location.
    _shutil.copy2(session.storage_state_path, cache_path)
    os.chmod(cache_path, 0o600)

    # Write companion metadata.
    meta_path = cache_path.with_suffix(".meta.json")
    meta = {
        "api_key": session.api_key,
        "key_name": session.key_name,
        "connect_url": session._connect_url,
    }
    meta_path.write_text(json.dumps(meta))
    os.chmod(meta_path, 0o600)


def start_interactive_auth(
    connect_url: str | None = None,
    workbench_url: str | None = None,
    cache_path: Path | None = None,
) -> InteractiveAuthSession:
    """Launch a headed browser, authenticate via OIDC, and optionally
    mint a Connect API key through the UI.

    At least one of *connect_url* or *workbench_url* must be provided.

    When *connect_url* is given, the browser opens Connect's login page
    and attempts to mint a temporary API key.  If *workbench_url* is
    also provided, the browser visits Workbench afterward so the saved
    storage state contains session cookies for both products (SSO handles
    the second authentication automatically).

    When only *workbench_url* is given, the browser opens Workbench
    directly.  No Connect API key is minted.

    The browser is closed before this function returns.  pytest-playwright
    creates its own browser instance using the saved storage state.
    """
    if not connect_url and not workbench_url:
        raise ValueError(
            "--interactive-auth requires at least one product URL (Connect or Workbench)"
        )

    # Check for a valid cached session.
    if cache_path:
        cached = _load_cached_auth(cache_path)
        if cached is not None:
            return cached

    # Determine the primary login target.
    primary_url = connect_url or workbench_url
    assert primary_url is not None  # guaranteed by the check above
    login_path = "/__login__" if connect_url else ""

    tmpdir = tempfile.mkdtemp(prefix="vip-auth-")
    storage_state_path = Path(tmpdir) / "vip-auth-state.json"
    os.chmod(tmpdir, 0o700)

    key_name = f"{_KEY_NAME_PREFIX}{int(time.time())}"

    pw = None
    browser = None
    try:
        pw = sync_playwright().start()
        browser = pw.chromium.launch(headless=False)
        context = browser.new_context()
        page = context.new_page()

        page.goto(f"{primary_url}{login_path}")

        print(f"\n>>> A browser window has opened at {primary_url}")
        print(">>> Please log in through your identity provider.")
        print(">>> The browser will close automatically after login.\n")

        # Poll until login completes
        base = primary_url.rstrip("/")
        deadline = time.monotonic() + 300
        login_completed = False

        # Login detection: for Connect check we left /__login__,
        # for Workbench check we're no longer on a login/auth page.
        while time.monotonic() < deadline:
            try:
                url = page.url
            except Exception:
                break
            if connect_url:
                if base in url and "/__login__" not in url:
                    login_completed = True
                    break
            else:
                # For Workbench, login is complete when we're on the
                # homepage (no login/auth keywords in the URL).
                lower = url.lower()
                at_login = any(kw in lower for kw in ("sign-in", "login", "auth"))
                if base.rstrip("/").lower() in lower and not at_login:
                    login_completed = True
                    break
            try:
                page.wait_for_timeout(500)
            except Exception:
                break

        if not login_completed:
            raise RuntimeError(
                "Login did not complete within 5 minutes. "
                "Please rerun and complete authentication in the browser window."
            )

        # Mint Connect API key only if Connect is configured.
        api_key = None
        if connect_url:
            api_key = _create_api_key_via_ui(page, connect_url, key_name)

        # Visit Workbench so the storage state includes its session cookies.
        if workbench_url and connect_url:
            _authenticate_workbench(page, workbench_url)

        context.storage_state(path=str(storage_state_path))

        session = InteractiveAuthSession(
            storage_state_path=storage_state_path,
            api_key=api_key,
            key_name=key_name,
            _connect_url=connect_url or "",
            _tmpdir=tmpdir,
        )

        # Cache the session for reuse across runs.
        if cache_path:
            _save_auth_cache(session, cache_path)

        return session
    except Exception:
        if tmpdir and os.path.isdir(tmpdir):
            shutil.rmtree(tmpdir, ignore_errors=True)
        raise
    finally:
        if browser is not None:
            try:
                browser.close()
            except Exception:
                pass
        if pw is not None:
            try:
                pw.stop()
            except Exception:
                pass


_LOGIN_KEYWORDS = ("sign-in", "login", "auth-sign-in")


def _on_login_page(url: str) -> bool:
    """Return True if *url* looks like a login or IdP page."""
    lower = url.lower()
    return any(kw in lower for kw in _LOGIN_KEYWORDS)


def _authenticate_workbench(page: Page, workbench_url: str) -> None:
    """Navigate to Workbench to establish an SSO session.

    After the user authenticated to Connect via OIDC, the identity provider
    already has an active session.  The typical redirect chain is:

    1. Workbench ``/`` → 302 to ``/auth-sign-in``
    2. ``/auth-sign-in`` → (auto-redirect or click) → IdP
    3. IdP (active session) → redirect back to Workbench with token
    4. Workbench sets session cookie → dashboard

    ``networkidle`` may fire at step 2 before the IdP redirect completes,
    so we poll until the URL is on the Workbench domain *and* is no longer
    a login page.

    If SSO does not resolve automatically (e.g. the auth-sign-in page
    requires a click), we attempt to click through.  The headed browser is
    still visible so the user can also intervene manually.
    """
    wb_base = workbench_url.rstrip("/").lower()
    print(f"\n>>> Authenticating to Workbench at {workbench_url} ...")

    page.goto(workbench_url)
    page.wait_for_load_state("networkidle")

    # Quick check — already on the Workbench dashboard?
    url = page.url
    if url.lower().startswith(wb_base) and not _on_login_page(url):
        print(">>> Workbench authenticated via SSO.\n")
        return

    # We're likely on /auth-sign-in.  Try clicking a sign-in button to
    # trigger the OIDC redirect (some Workbench configs don't auto-redirect).
    for selector in (
        "a:has-text('Sign in')",
        "button:has-text('Sign in')",
        "a:has-text('Log in')",
        "button:has-text('Log in')",
        "#auth-sign-in-link",
    ):
        try:
            page.click(selector, timeout=2_000)
            break
        except Exception:
            continue

    # Wait for the OIDC redirect chain to complete.
    print(">>> Waiting for Workbench SSO redirect chain ...")
    print(">>> If prompted, please complete authentication in the browser.\n")

    deadline = time.monotonic() + 120  # 2-minute timeout
    while time.monotonic() < deadline:
        try:
            page.wait_for_load_state("networkidle", timeout=5_000)
        except Exception:
            pass
        try:
            url = page.url
        except Exception:
            break
        if url.lower().startswith(wb_base) and not _on_login_page(url):
            print(">>> Workbench authenticated.\n")
            return
        try:
            page.wait_for_timeout(500)
        except Exception:
            break

    print(
        ">>> Warning: Workbench authentication did not complete within 2 minutes.\n"
        ">>> Workbench browser tests may skip.\n"
    )


def _create_api_key_via_ui(page: Page, connect_url: str, key_name: str) -> str | None:
    """Navigate the Connect UI to create an API key.

    Also deletes any orphaned ``_vip_interactive_*`` keys left over from
    previous runs that crashed before cleanup.

    Returns the API key string, or None on failure.
    """
    base = connect_url.rstrip("/")

    try:
        # Navigate to the Connect dashboard
        page.goto(f"{base}/connect/#/")
        page.wait_for_load_state("networkidle")
        page.wait_for_timeout(2_000)

        # Open user dropdown by clicking the user panel area (top-right).
        # Uses JS to find the element by position since Connect versions
        # vary in their markup.
        page.evaluate(
            """() => {
            const els = document.querySelectorAll('a, button, [role="button"], span');
            for (const el of els) {
                const rect = el.getBoundingClientRect();
                if (rect.right > window.innerWidth - 200 && rect.top < 60) {
                    const text = el.textContent || '';
                    if (text.includes('.') && text.length < 30) {
                        el.click();
                        return;
                    }
                }
            }
        }"""
        )
        page.wait_for_timeout(1_000)

        page.get_by_text("Manage Your API Keys").click(timeout=5_000)
        page.wait_for_load_state("networkidle")
        page.wait_for_timeout(1_000)

        # Delete orphaned VIP keys from previous runs
        _delete_orphaned_keys(page)

        # Click "+ New API Key"
        page.locator("text=New API Key").first.click(timeout=5_000)
        page.wait_for_timeout(1_000)

        # Fill in the key name
        name_input = page.locator("input[type='text']").first
        name_input.fill(key_name)
        page.wait_for_timeout(300)

        # Click Create button
        page.locator("button:has-text('Create'),button[type='submit']").first.click(timeout=5_000)
        page.wait_for_timeout(1_000)

        # Extract the generated key — Connect shows it in a read-only
        # input, a code block, or a text element in the dialog
        api_key = None
        for selector in [
            "input[readonly]",
            "code",
            ".api-key-value",
            "pre",
            "[data-automation='api-key-value']",
        ]:
            el = page.locator(selector).first
            try:
                val = el.input_value(timeout=2_000)
            except Exception:
                try:
                    val = el.text_content(timeout=2_000) or ""
                except Exception:
                    continue
            if val and len(val) > 20:
                api_key = val.strip()
                break

        if not api_key:
            print(">>> Warning: Could not read API key from Connect UI.")
            print(">>> Set VIP_CONNECT_API_KEY manually for API-based tests.\n")
            return None

        print(">>> Connect API key created via UI.\n")

        # Close the dialog
        try:
            page.locator(
                "button:has-text('Close'),"
                "button:has-text('Done'),"
                "button:has-text('OK'),"
                "[aria-label='Close']"
            ).first.click(timeout=3_000)
        except Exception:
            page.keyboard.press("Escape")

        return api_key
    except Exception as exc:
        print(f">>> Warning: Could not create API key via UI: {exc}")
        print(">>> Set VIP_CONNECT_API_KEY manually for API-based tests.\n")
        return None


def _delete_orphaned_keys(page: Page) -> None:
    """Delete any leftover _vip_interactive_* keys visible on the API Keys page."""
    try:
        rows = page.locator("tr, [role='row']").all()
        for row in rows:
            text = row.text_content() or ""
            if _KEY_NAME_PREFIX in text:
                delete_btn = row.locator(
                    "button[aria-label='Delete'], button:has-text('Delete'), [title='Delete']"
                ).first
                try:
                    delete_btn.click(timeout=2_000)
                    # Confirm deletion if a dialog appears
                    page.locator("button:has-text('Yes'),button:has-text('Delete')").first.click(
                        timeout=2_000
                    )
                    page.wait_for_timeout(500)
                except Exception:
                    pass
    except Exception:
        pass  # Best-effort cleanup of orphans


def _delete_api_key(connect_url: str, api_key: str, key_name: str) -> None:
    """Delete the VIP API key using the key itself for authentication."""
    import httpx

    base = connect_url.rstrip("/")
    with httpx.Client(
        base_url=f"{base}/__api__",
        headers={"Authorization": f"Key {api_key}"},
        timeout=10.0,
    ) as client:
        for keys_path in ("/v1/user/api_keys", "/keys"):
            resp = client.get(keys_path)
            if resp.status_code == 404:
                continue
            if not resp.is_success:
                print(f">>> Warning: {keys_path} returned HTTP {resp.status_code}")
                continue
            for k in resp.json():
                if k.get("name") == key_name:
                    del_resp = client.delete(f"{keys_path}/{k['id']}")
                    if del_resp.is_success:
                        print(">>> API key deleted.\n")
                    else:
                        print(
                            f">>> Warning: DELETE {keys_path}/{k['id']}"
                            f" returned {del_resp.status_code}"
                        )
                    return
            break
        print(">>> Warning: Could not find API key to delete.\n")
