import os
import re
import time
import warnings
from pathlib import Path
from playwright.sync_api import sync_playwright

import cv2
import easyocr

# ============================================================
# WARNING SUPPRESSION
# ============================================================
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", category=DeprecationWarning)
os.environ["PYTHONWARNINGS"] = "ignore"

# ============================================================
# LOGIN CONFIGURATION
# ============================================================
USERNAME = "canofy01"
PASSWORD = "Siddhartha#2026"

BASE_URL = "https://www.sezonline-ndml.co.in/User/Login.aspx"

# ============================================================
# EXPORT CONFIGURATION
# (pull these from your framework's config.py / config.yaml —
#  placeholders below just show the expected format)
# ============================================================
FROM_DATE = "01/06/2026"   # dd/mm/yyyy — must match the portal's masked date format
TO_DATE = "18/06/2026"

DOWNLOAD_DIR = Path.home() / "Downloads" / "SEZ_Reports"
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)

# (module dropdown value, label used only for logging)
MODULES_TO_EXPORT = [
    ("17", "BILL OF ENTRY"),
    ("26", "DTA SALE"),
]

SCREENSHOT_DIR = Path(__file__).parent / "screenshots"
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)

# Initialize EasyOCR Reader ONCE (module load, not per captcha)
ocr_reader = easyocr.Reader(["en"], gpu=False)


# ============================================================
# CUSTOM EXCEPTIONS
# ============================================================
class RateLimitError(Exception):
    """Raised when SEZ Online reports that the request rate is limited."""
    pass


class SessionExpiredError(Exception):
    """Raised when the SEZ Online server-side session has timed out."""
    pass


class CaptchaUnsolvedError(Exception):
    """Raised when the CAPTCHA could not be confidently read after retries."""
    pass


RATE_LIMIT_WAIT_SECONDS = 120

# ============================================================
# CAPTCHA CONFIGURATION
# ============================================================
CAPTCHA_MIN_LENGTH = 4
CAPTCHA_MAX_LENGTH = 8
MAX_CAPTCHA_READ_ATTEMPTS = 6
MAX_LOGIN_ATTEMPTS = 8


# ============================================================
# ACTIVE IDLE (SESSION KEEP-ALIVE) PAUSE
# ============================================================
def idle_keepalive_pause(page, total_seconds: int, chunk_seconds: int = 5):
    """Wait out a required cooldown without letting the browser tab go fully idle."""
    print(f"Idling for {total_seconds}s with periodic keep-alive scrolling...")

    elapsed = 0.0
    direction = 1

    while elapsed < total_seconds:
        chunk_start = time.time()
        remaining = total_seconds - elapsed
        this_chunk = min(chunk_seconds, remaining)

        try:
            steps = max(1, int(round(this_chunk)))
            step_delta = (300 * direction) / steps
            step_wait_ms = int((this_chunk / steps) * 1000)

            for _ in range(steps):
                page.mouse.wheel(0, step_delta)
                page.wait_for_timeout(step_wait_ms)
        except Exception:
            time.sleep(this_chunk)

        direction *= -1
        elapsed += (time.time() - chunk_start)

    print("Keep-alive idling complete.")


# ============================================================
# CAPTCHA ENGINE (unchanged from the print-automation script)
# ============================================================
def is_valid_captcha_token(token: str) -> bool:
    if not token:
        return False
    if not token.isalnum():
        return False
    if not (CAPTCHA_MIN_LENGTH <= len(token) <= CAPTCHA_MAX_LENGTH):
        return False
    return True


def resolve_captcha(image_path: Path) -> str:
    """Multi-variant OCR with confidence-weighted consensus voting."""
    image = cv2.imread(str(image_path))

    if image is None:
        raise FileNotFoundError(f"Could not load CAPTCHA image from {image_path}")

    resized = cv2.resize(image, None, fx=2.5, fy=2.5, interpolation=cv2.INTER_CUBIC)
    gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)

    variants = {}

    filtered = cv2.bilateralFilter(gray, 9, 75, 75)
    variants["bilateral"] = filtered

    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
    variants["enhanced"] = clahe.apply(filtered)

    _, otsu = cv2.threshold(filtered, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    variants["otsu"] = otsu

    variants["adaptive"] = cv2.adaptiveThreshold(
        filtered, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2
    )

    allowlist = (
        "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
        "abcdefghijklmnopqrstuvwxyz"
        "0123456789"
    )

    candidates = []

    for variant_name, variant_image in variants.items():
        try:
            results = ocr_reader.readtext(
                variant_image, detail=1, paragraph=False, allowlist=allowlist
            )
        except Exception as exc:
            print(f"OCR failed for {variant_name}: {exc}")
            continue

        if not results:
            continue

        try:
            results = sorted(results, key=lambda item: min(point[0] for point in item[0]))
        except Exception:
            pass

        raw_text = "".join(str(item[1]) for item in results if len(item) >= 2)
        cleaned_text = re.sub(r"[^A-Za-z0-9]", "", raw_text)

        if not cleaned_text:
            continue

        confidences = [float(item[2]) for item in results if len(item) >= 3]
        avg_confidence = (sum(confidences) / len(confidences)) if confidences else 0.0

        candidates.append({
            "variant": variant_name,
            "text": cleaned_text,
            "confidence": avg_confidence,
        })

    if not candidates:
        return ""

    grouped = {}
    for candidate in candidates:
        token = candidate["text"]
        bucket = grouped.setdefault(token, {"text": token, "confidence_sum": 0.0, "count": 0})
        bucket["confidence_sum"] += candidate["confidence"]
        bucket["count"] += 1

    grouped_candidates = list(grouped.values())
    for candidate in grouped_candidates:
        candidate["average_confidence"] = candidate["confidence_sum"] / candidate["count"]
        candidate["score"] = candidate["average_confidence"] + (0.05 * (candidate["count"] - 1))

    grouped_candidates.sort(key=lambda item: item["score"], reverse=True)
    best = grouped_candidates[0]

    case_groups = {}
    for candidate in candidates:
        key = candidate["text"].lower()
        case_groups.setdefault(key, []).append(candidate)

    compatible_group = case_groups.get(best["text"].lower(), [])
    best_text = best["text"]

    if compatible_group:
        max_length = max(len(item["text"]) for item in compatible_group)
        final_chars = []

        for index in range(max_length):
            char_votes = {}
            for candidate in compatible_group:
                text = candidate["text"]
                if index >= len(text):
                    continue
                char = text[index]
                char_votes[char] = char_votes.get(char, 0.0) + candidate["confidence"]

            if char_votes:
                final_chars.append(max(char_votes, key=char_votes.get))

        consensus_text = "".join(final_chars)
        if consensus_text:
            best_text = consensus_text

    cleaned_token = re.sub(r"[^A-Za-z0-9]", "", best_text)

    print(f"CAPTCHA OCR candidates: {[(c['variant'], c['text'], round(c['confidence'], 3)) for c in candidates]}")
    print(f"CAPTCHA consensus token: '{cleaned_token}'")

    return cleaned_token


def try_refresh_captcha_image(page) -> bool:
    candidate_selectors = [
        "#captchaControlRevamp_btnRefreshCaptcha",
        "#captchaControlRevamp_imgRefresh",
        "a[id*='Refresh' i][id*='aptcha' i]",
        "img[id*='Refresh' i][id*='aptcha' i]",
        "a[href*='refresh' i][id*='aptcha' i]",
        "[title*='refresh' i][id*='aptcha' i]",
        "[title*='reload' i][id*='aptcha' i]",
    ]

    for selector in candidate_selectors:
        try:
            control = page.locator(selector).first
            if control.count() > 0 and control.is_visible():
                control.click(timeout=3000)
                page.wait_for_timeout(500)
                return True
        except Exception:
            continue

    return False


def get_captcha_token(page, captcha_image_locator) -> str:
    raw_captcha_path = SCREENSHOT_DIR / "raw_captcha.png"
    best_fallback = ""

    for attempt in range(1, MAX_CAPTCHA_READ_ATTEMPTS + 1):
        try:
            captcha_image_locator.wait_for(state="visible", timeout=15000)
            captcha_image_locator.screenshot(path=str(raw_captcha_path))
        except Exception as exc:
            print(f"Captcha screenshot attempt {attempt} failed: {exc}")
            continue

        token = resolve_captcha(raw_captcha_path)

        if token and len(token) > len(best_fallback):
            best_fallback = token

        if is_valid_captcha_token(token):
            print(f"Captcha read accepted on attempt {attempt}: '{token}'")
            return token

        print(
            f"Captcha read attempt {attempt}/{MAX_CAPTCHA_READ_ATTEMPTS} "
            f"rejected (raw='{token}'). Trying to get a fresh captcha image..."
        )

        refreshed = try_refresh_captcha_image(page)
        if not refreshed:
            break

    if best_fallback:
        print(f"No structurally valid captcha read; using best guess: '{best_fallback}'")
        return best_fallback

    raise CaptchaUnsolvedError("Could not obtain any usable CAPTCHA reading.")


# ============================================================
# PERMANENT POPUP & OVERLAY IMMUNIZATION
# ============================================================
def immunize_page_against_popups(context):
    """
    Disables driver.js (welcome tour) and injects style blockades for stray
    overlays, plus a lightweight interval that keeps stripping the
    'driver-active' / 'driver-fade' body/html classes and known overlay
    elements if they reappear.

    IMPORTANT: this intentionally does NOT use a MutationObserver watching
    the whole document (subtree + childList). That was tried first and
    caused a worse regression: on this portal's page (which does its own
    AJAX/DOM churn via postbacks and update panels), a document-wide
    observer meant every unrelated DOM change anywhere on the page fired
    our callback, which itself mutates the DOM (removing elements),
    re-triggering the observer -- a feedback loop that pegged the page's
    JS thread and made the whole page look "stuck loading" and
    unresponsive, even before any of our own script's actions ran. A
    plain, modestly-scoped interval avoids that feedback loop while still
    catching the overlay if it reappears.
    """
    context.add_init_script("""
        window.Driver = function() {
            return {
                start: function() {},
                reset: function() {},
                destroy: function() {},
                defineSteps: function() {},
                showNextStep: function() {},
                preventMove: function() {}
            };
        };

        const style = document.createElement('style');
        style.innerHTML = `
            .driver-overlay, .driver-popover, .driver-popover-tip,
            .driver-popover-footer, .driver-popover-description,
            .driver-popover-title, .driver-stage-no-animation,
            .modal-backdrop, .overlay {
                display: none !important;
                visibility: hidden !important;
                pointer-events: none !important;
                width: 0 !important;
                height: 0 !important;
                opacity: 0 !important;
            }
            body, html {
                overflow: auto !important;
                pointer-events: auto !important;
            }
        `;
        document.head ? document.head.appendChild(style) : document.addEventListener('DOMContentLoaded', () => document.head.appendChild(style));

        const stripOverlayState = () => {
            if (document.body && document.body.classList.contains('driver-active')) {
                document.body.classList.remove('driver-active', 'driver-fade');
                document.body.style.overflow = 'auto';
                document.body.style.pointerEvents = 'auto';
            }
            if (document.documentElement && document.documentElement.classList.contains('driver-active')) {
                document.documentElement.classList.remove('driver-active', 'driver-fade');
            }
            const strayOverlays = document.querySelectorAll(
                '.driver-overlay, .driver-popover, .driver-popover-tip, .driver-popover-footer, .modal-backdrop, .overlay'
            );
            if (strayOverlays.length > 0) {
                strayOverlays.forEach((el) => el.remove());
            }
        };

        const startPolling = () => {
            stripOverlayState();
            // Plain interval only -- no MutationObserver. See docstring above.
            setInterval(stripOverlayState, 400);
        };

        if (document.body) {
            startPolling();
        } else {
            document.addEventListener('DOMContentLoaded', startPolling);
        }
    """)


def clean_popups_now(page):
    try:
        page.evaluate("""
            document.querySelectorAll('.driver-overlay, .driver-popover, .driver-popover-tip, .driver-popover-footer, .modal-backdrop, .overlay').forEach(el => el.remove());
            if (document.body) {
                document.body.classList.remove('driver-active', 'driver-fade');
                document.body.style.overflow = 'auto';
                document.body.style.pointerEvents = 'auto';
            }
            if (document.documentElement) {
                document.documentElement.classList.remove('driver-active', 'driver-fade');
                document.documentElement.style.overflow = 'auto';
            }
        """)
    except Exception:
        pass


def handle_welcome_popup(page):
    """Belt-and-braces close of the Welcome Tour, mirroring the framework's dashboard page."""
    try:
        page.wait_for_timeout(1000)
        popup = page.locator(".driver-popover")
        close_btn = page.locator(".driver-popover-close-btn")
        if popup.count() > 0 and popup.is_visible():
            print("Welcome Tour detected, closing...")
            close_btn.evaluate("(el) => el.click()")
            popup.wait_for(state="hidden", timeout=5000)
            print("Welcome Tour closed.")
    except Exception as exc:
        print(f"Unable to close Welcome Tour (continuing): {exc}")

    clean_popups_now(page)


def safe_click(page, locator_or_selector):
    clean_popups_now(page)

    loc = (
        locator_or_selector
        if hasattr(locator_or_selector, "click")
        else page.locator(locator_or_selector)
    )

    try:
        loc.scroll_into_view_if_needed(timeout=5000)
    except Exception:
        pass

    try:
        loc.click(timeout=10000, force=True)
    except Exception as first_exc:
        try:
            loc.click(timeout=10000)
        except Exception as second_exc:
            raise RuntimeError(f"Could not click portal control: {second_exc}") from first_exc


def check_rate_limit(page) -> None:
    try:
        current_url = page.url
    except Exception:
        current_url = ""

    if "SessionTimeOut.aspx" in current_url or "sessionexpired=true" in current_url.lower():
        raise SessionExpiredError(
            "SEZ Online session has expired (redirected to SessionTimeOut.aspx)."
        )

    try:
        body_text = page.locator("body").inner_text(timeout=2000)
    except Exception:
        return

    normalized = re.sub(r"\s+", " ", body_text or "").strip().lower()

    if "session has expired" in normalized or "session expired" in normalized:
        raise SessionExpiredError(
            "SEZ Online session has expired ('Your Session has expired' banner detected)."
        )

    if "rate limit exceeded" in normalized or "try again in 120 seconds" in normalized:
        raise RateLimitError(
            "SEZ Online rate limit exceeded. The portal requested a retry after 120 seconds."
        )


def recover_from_session_expiry(page):
    print()
    print("=" * 60)
    print("SESSION EXPIRED — RE-AUTHENTICATING")
    print("=" * 60)
    print("Current URL:", page.url)

    try:
        here_link = page.get_by_role("link", name="Here")
        if here_link.count() > 0:
            print("Clicking 'Here' to return to login...")
            here_link.first.click(timeout=5000)
            page.wait_for_load_state("domcontentloaded", timeout=15000)
            page.wait_for_timeout(500)
    except Exception as exc:
        print(f"Could not click 'Here' link (continuing anyway): {exc}")

    login(page)

    print("Re-authentication after session expiry: SUCCESSFUL.")
    print("=" * 60)


def login_failed_reason(page) -> str:
    if "Login.aspx" in page.url:
        try:
            body_text = page.locator("body").inner_text(timeout=2000)
        except Exception:
            body_text = ""

        normalized = re.sub(r"\s+", " ", body_text or "").strip().lower()

        if "invalid captcha" in normalized or "captcha" in normalized and "invalid" in normalized:
            return "invalid captcha"
        if "invalid" in normalized and ("username" in normalized or "password" in normalized):
            return "invalid credentials"
        return "still on login page"

    return ""


# ============================================================
# LOGIN FLOW (same retry logic as the print-automation script)
# ============================================================
def login(page):
    print()
    print("=" * 60)
    print("SEZ ONLINE LOGIN (AUTOMATED OCR)")
    print("=" * 60)

    for attempt in range(1, MAX_LOGIN_ATTEMPTS + 1):
        print(f"\n--- Login attempt {attempt}/{MAX_LOGIN_ATTEMPTS} ---")

        print("Opening login page...")
        page.goto(BASE_URL, wait_until="domcontentloaded")
        clean_popups_now(page)

        username_input = page.get_by_role("textbox", name="User Name")
        password_input = page.get_by_role("textbox", name="Password")
        captcha_input = page.get_by_role("textbox", name="Captcha")
        login_button = page.get_by_role("button", name="Login")
        captcha_image = page.locator("#captchaControlRevamp_imgCaptcha")

        print("Entering username & password...")
        username_input.wait_for(state="visible", timeout=30000)
        username_input.fill(USERNAME)
        password_input.fill(PASSWORD)

        try:
            captcha_token = get_captcha_token(page, captcha_image)
        except CaptchaUnsolvedError as exc:
            print(f"Captcha could not be solved this attempt: {exc}")
            continue

        print(f"Entering extracted CAPTCHA: '{captcha_token}'")
        captcha_input.fill(captcha_token)

        print("Submitting login form...")
        clean_popups_now(page)
        safe_click(page, login_button)

        try:
            page.wait_for_load_state("domcontentloaded", timeout=15000)
        except Exception:
            pass

        page.wait_for_timeout(1500)
        clean_popups_now(page)
        print("Login submission completed. Current URL:", page.url)

        if "ConfirmUserCredentials.aspx" in page.url:
            print("Handling ConfirmUserCredentials screen...")
            try:
                confirm_btn = page.locator(
                    "input[type='submit'][value*='Continue'], "
                    "input[type='submit'][value*='OK'], "
                    "#btnSubmit, #btnConfirm"
                )
                if confirm_btn.count() > 0:
                    safe_click(page, confirm_btn.first)
                    page.wait_for_load_state("domcontentloaded", timeout=10000)
                    page.wait_for_timeout(1000)
            except Exception:
                pass

        failure_reason = login_failed_reason(page)
        if not failure_reason:
            print("Login SUCCESSFUL.")
            return

        print(f"Login attempt {attempt} failed ({failure_reason}). Retrying with a fresh captcha...")

    raise RuntimeError(
        f"Could not log in to SEZ Online after {MAX_LOGIN_ATTEMPTS} attempts "
        "(captcha and/or credential failures)."
    )


# ============================================================
# MASKED DATE FIELD HELPER
# ============================================================
def enter_masked_date(page, locator, date_str: str):
    """
    Clears a masked date field and types the value character-by-character,
    which is what the portal's JS date mask expects (a plain .fill() can
    leave the mask in a half-applied state).

    Routes the click through safe_click() (popup cleanup + forced click)
    instead of a raw locator.click(). A raw click here was the concrete
    cause of the "stuck / scrolling" hang: if the tour overlay reasserted
    itself right before this specific click, nothing cleared it first, so
    Playwright retried the click against a body that kept intercepting
    pointer events until the 30s timeout.
    """
    clean_popups_now(page)
    safe_click(page, locator)
    locator.evaluate("(el) => el.value = ''")
    if date_str:
        locator.type(date_str, delay=50)


# ============================================================
# EXPORT TO EXCEL (equivalent of navigate_to_export_excel)
# ============================================================
def navigate_to_export_excel(page, from_date: str, to_date: str, download_dir: Path, modules):
    """
    Reports -> Trade Data -> for each module: select module, set date range
    (first pass only), click EXPORT TO EXCEL, capture the browser download,
    save it to download_dir. Mirrors the framework's SEZDashboardPage
    method, with the same rate-limit / session-expiry checks used by the
    print-automation script layered in around each step.
    """
    downloaded_files = []

    print()
    print("=" * 60)
    print("NAVIGATE TO EXPORT EXCEL")
    print("=" * 60)

    check_rate_limit(page)

    try:
        page.wait_for_load_state("domcontentloaded", timeout=15000)
    except Exception:
        pass

    handle_welcome_popup(page)
    check_rate_limit(page)

    print("Opening Reports...")
    reports_link = page.get_by_role("link", name="Reports", exact=True)
    reports_link.wait_for(state="visible", timeout=30000)
    check_rate_limit(page)
    safe_click(page, reports_link)

    try:
        page.wait_for_load_state("domcontentloaded", timeout=15000)
    except Exception:
        pass
    page.wait_for_timeout(500)
    clean_popups_now(page)
    check_rate_limit(page)

    print("Opening Trade Data...")
    trade_data_link = page.locator("#ctl00_DefaultContent_hpTradeData")
    trade_data_link.wait_for(state="visible", timeout=30000)
    check_rate_limit(page)
    safe_click(page, trade_data_link)

    try:
        page.wait_for_load_state("domcontentloaded", timeout=15000)
    except Exception:
        pass
    page.wait_for_timeout(800)
    clean_popups_now(page)
    check_rate_limit(page)

    module_dropdown = page.locator("#ctl00_DefaultContent_ddlModuleName")
    from_date_input = page.locator("#ctl00_DefaultContent_dtFromDt_txtMaskedDate")
    to_date_input = page.locator("#ctl00_DefaultContent_dtToDate_txtMaskedDate")
    export_button = page.get_by_role("button", name="EXPORT TO EXCEL")
    logout_button = page.locator("#ctl00_LoginStatus")

    for index, (module_value, module_label) in enumerate(modules):
        print(f"Selecting module: {module_label} (value={module_value})...")
        module_dropdown.wait_for(state="visible", timeout=15000)
        check_rate_limit(page)
        module_dropdown.select_option(module_value)

        # The module dropdown triggers an ASP.NET postback on this screen,
        # so wait for the page to actually settle (not just a fixed sleep)
        # before touching anything else on it.
        try:
            page.wait_for_load_state("domcontentloaded", timeout=15000)
        except Exception:
            pass
        page.wait_for_timeout(800)
        clean_popups_now(page)

        # Dates only need to be (re)entered once; they persist across
        # module switches on this screen, matching the framework's flow.
        if index == 0:
            print(f"Setting date range: {from_date} to {to_date}...")
            from_date_input.wait_for(state="visible", timeout=15000)
            to_date_input.wait_for(state="visible", timeout=15000)
            check_rate_limit(page)
            enter_masked_date(page, from_date_input, from_date)
            enter_masked_date(page, to_date_input, to_date)
            page.wait_for_timeout(300)

        print(f"Exporting {module_label} to Excel...")
        export_button.wait_for(state="visible", timeout=15000)
        check_rate_limit(page)

        with page.expect_download(timeout=30000) as download_info:
            safe_click(page, export_button)

        download = download_info.value
        suggested_name = download.suggested_filename
        file_path = download_dir / suggested_name

        # Avoid clobbering a same-named file from a previous run/module.
        if file_path.exists():
            stem, suffix = file_path.stem, file_path.suffix
            counter = 1
            while file_path.exists():
                file_path = download_dir / f"{stem}_{counter}{suffix}"
                counter += 1

        download.save_as(str(file_path))
        print(f"Downloaded: {file_path}")
        downloaded_files.append(str(file_path))

        page.wait_for_timeout(1000)
        clean_popups_now(page)
        check_rate_limit(page)

    print("Logging out...")
    logout_button.wait_for(state="visible", timeout=30000)
    safe_click(page, logout_button)

    try:
        page.wait_for_load_state("domcontentloaded", timeout=15000)
    except Exception:
        pass
    page.wait_for_timeout(1000)

    print("Logged out successfully.")
    print("=" * 60)
    print("EXPORT COMPLETE — Files downloaded:")
    for f in downloaded_files:
        print(" -", f)
    print("=" * 60)

    return downloaded_files


# ============================================================
# MAIN ENTRY POINT
# ============================================================
def main():
    print()
    print("=" * 60)
    print("SEZ EXPORT-TO-EXCEL AUTOMATION")
    print("=" * 60)

    with sync_playwright() as p:
        browser = p.chromium.launch(
            headless=False,
            args=["--start-maximized", "--no-sandbox"],
        )

        context = browser.new_context(
            no_viewport=True,
            accept_downloads=True,
            user_agent=(
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/120.0.0.0 Safari/537.36"
            ),
        )

        immunize_page_against_popups(context)

        def auto_accept_dialogs(dialog):
            print(f"\n[Dialog Intercepted] {dialog.message}")
            dialog.accept()

        context.on("dialog", auto_accept_dialogs)

        page = context.new_page()

        try:
            # STEP 1: LOGIN (with internal captcha/login retries)
            login(page)

            # Diagnostic snapshot right after login, before anything else
            # runs. If the page hangs/looks stuck on the NEXT step, this
            # gives us a picture of exactly what state it was actually in,
            # independent of whether the browser later gets closed manually.
            try:
                page.wait_for_load_state("networkidle", timeout=15000)
            except Exception as wait_exc:
                print(f"Post-login networkidle wait did not complete (continuing): {wait_exc}")

            try:
                post_login_path = SCREENSHOT_DIR / "post_login.png"
                page.screenshot(path=str(post_login_path), full_page=True)
                print(f"Saved post-login diagnostic screenshot to: {post_login_path}")
            except Exception as shot_exc:
                print(f"Could not save post-login screenshot: {shot_exc}")

            # STEP 2: EXPORT, with rate-limit / session-expiry retry loop
            session_retries = 0

            while True:
                try:
                    check_rate_limit(page)
                    navigate_to_export_excel(page, FROM_DATE, TO_DATE, DOWNLOAD_DIR, MODULES_TO_EXPORT)
                    break

                except SessionExpiredError as session_exc:
                    session_retries += 1
                    print()
                    print("=" * 60)
                    print("SEZ ONLINE SESSION EXPIRED")
                    print("=" * 60)
                    print(f"Reason: {session_exc}")
                    print(f"Re-login attempt {session_retries}/5")

                    if session_retries > 5:
                        raise RuntimeError(
                            "Session kept expiring during export after 5 re-login attempts."
                        ) from session_exc

                    recover_from_session_expiry(page)
                    print("Retrying export after re-login...")
                    continue

                except RateLimitError as rate_exc:
                    print()
                    print("=" * 60)
                    print("SEZ ONLINE RATE LIMIT DETECTED")
                    print("=" * 60)
                    print(f"Reason: {rate_exc}")
                    print(f"Waiting {RATE_LIMIT_WAIT_SECONDS} seconds before retrying...")

                    idle_keepalive_pause(page, RATE_LIMIT_WAIT_SECONDS)

                    try:
                        page.reload(wait_until="domcontentloaded", timeout=30000)
                    except Exception as reload_exc:
                        print("Page reload warning:", reload_exc)

                    page.wait_for_timeout(1500)
                    clean_popups_now(page)

                    print("Retrying export from the beginning...")
                    continue

        except Exception as exc:
            print()
            print("=" * 60)
            print("SEZ EXPORT PIPELINE FAILED")
            print("=" * 60)
            print(f"{type(exc).__name__}: {exc}")

            failure_path = SCREENSHOT_DIR / "export_failure.png"
            page.screenshot(path=str(failure_path), full_page=True)
            print("Saved failure diagnostic screenshot to:", failure_path)

            raise

        finally:
            context.close()
            browser.close()

    print()
    print("=" * 60)
    print("SEZ EXPORT-TO-EXCEL AUTOMATION COMPLETED")
    print("=" * 60)


if __name__ == "__main__":
    main()
