﻿#!/usr/bin/env python3
"""Enter_PowerBi_Selector.py
----------------------------

What this does (robust session handoff)
1) Finds runnable scripts (same folder + one level down).
2) Uses your saved selection by default (so you don't need to re-choose each run).
3) If no saved selection exists (or you pass --configure-selection), it shows the GUI.
4) After script selection is resolved:
   - Deletes any *stale* powerbi_session.json in the folder (so we don't attach to dead ports)
   - Starts Powerbi_Background/Open_PowerBi.py (non-blocking)
   - Waits until Open_PowerBi.py writes a fresh session file AND the geckodriver port is reachable
   - Runs the selected scripts one-by-one (blocking)
5) When all selected scripts finish:
   - If "Keep Power BI open when finished" is unchecked, it force-closes Open_PowerBi.py
     and its child processes (Firefox/geckodriver).
   - If checked, it leaves Power BI open and exits.

Requirements
- Python 3
- Tkinter available (Ubuntu/Mint: sudo apt install python3-tk)

Run (GUI):
  python3 Enter_PowerBi_Selector.py

Configure or change saved selection:
  python3 Enter_PowerBi_Selector.py --configure-selection

Run (headless / no GUI, uses saved selection if available):
  python3 Enter_PowerBi_Selector.py --headless

If no saved selection exists yet, headless mode falls back to all non-hidden scripts.

Keep Power BI open after running (headless mode):
  python3 Enter_PowerBi_Selector.py --headless --keep-open
"""

from __future__ import annotations

import json
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
from typing import List
from urllib.parse import urlparse

import argparse

ROOT_DIR = Path(__file__).resolve().parent
WORKSPACE_DIR = ROOT_DIR / "Pulled_Info"
if str(WORKSPACE_DIR) not in sys.path:
    sys.path.insert(0, str(WORKSPACE_DIR))

from Powerbi_Background.Script_Selector import (
    choose_scripts_gui,
    get_displayable_scripts,
    load_saved_selection,
    save_selection,
)

try:
    from tkinter import messagebox  # type: ignore
except ImportError:
    messagebox = None  # type: ignore


SESSION_FILENAME = "config/powerbi_session.json"

# --- Hide scripts from the GUI list (case-insensitive) ---
HIDE_SCRIPT_NAMES = {
    # Exact filenames:
    "RW_Site_Scraper-Orders_Page.py",
    "Inventory_Search_GUI.py",
    "Launch_Inventory_Search.py",
    # "my_internal_tool.py",
    # "debug_export.py",
}

HIDE_SCRIPT_NAME_CONTAINS = {
    # Substrings anywhere in the filename:
    # "old",
    # "test",
    # "_wip",
}

HIDE_FOLDERS = {
    "Boot_Features",
    "config",
    "docs",
    "downloads",
    "tools",
    "__pycache__",
    ".git",
    ".venv",
    "Powerbi_Background",
    "Voucher_List_Folder",
}


def _pick_session_file(base_dir: Path) -> Path:
    """Match the same search order as your attach scripts."""
    override = os.environ.get("POWERBI_SESSION_FILE", "").strip()
    if override:
        return Path(override).expanduser().resolve()

    return (base_dir / SESSION_FILENAME).resolve()


def _resolve_saved_scripts(base_dir: Path, available: List[Path], saved_rel: List[str]) -> List[Path]:
    """Map saved repo-relative script paths back to existing script paths."""
    by_rel = {p.relative_to(base_dir).as_posix(): p for p in available}
    return [by_rel[rel] for rel in saved_rel if rel in by_rel]


def _popen_group(cmd: List[str], cwd: str, env: dict | None = None) -> subprocess.Popen:
    """Start a subprocess in its own process group/session (cross-platform)."""
    if os.name == "nt":
        CREATE_NEW_PROCESS_GROUP = 0x00000200
        return subprocess.Popen(cmd, cwd=cwd, env=env, creationflags=CREATE_NEW_PROCESS_GROUP)
    return subprocess.Popen(cmd, cwd=cwd, env=env, start_new_session=True)


def _terminate_process_tree(proc: subprocess.Popen, grace_seconds: float = 5.0) -> None:
    """Best-effort: terminate a process and its children."""
    if proc.poll() is not None:
        return

    try:
        if os.name == "nt":
            subprocess.run(
                ["taskkill", "/PID", str(proc.pid), "/T", "/F"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                check=False,
            )
            return

        os.killpg(proc.pid, signal.SIGTERM)
        t0 = time.time()
        while time.time() - t0 < grace_seconds:
            if proc.poll() is not None:
                return
            time.sleep(0.1)
        os.killpg(proc.pid, signal.SIGKILL)
    except Exception:
        try:
            proc.terminate()
        except Exception:
            pass


def _socket_reachable(url: str, timeout: float = 1.0) -> bool:
    """Check if the executor_url host:port is accepting TCP connections."""
    p = urlparse(url)
    host = p.hostname or "127.0.0.1"
    port = p.port
    if port is None:
        port = 443 if p.scheme == "https" else 80
    try:
        with socket.create_connection((host, port), timeout=timeout):
            return True
    except OSError:
        return False


def _wait_for_fresh_session(proc: subprocess.Popen, base_dir: Path, timeout: int = 180) -> dict:
    """Wait until powerbi_session.json exists, parses, and the port is stably reachable."""
    session_path = _pick_session_file(base_dir)
    start = time.time()

    while time.time() - start < timeout:
        if proc.poll() is not None:
            raise RuntimeError(
                f"Open_PowerBi.py exited early with code {proc.returncode}. "
                f"It never produced a usable {SESSION_FILENAME}."
            )

        if session_path.exists():
            try:
                data = json.loads(session_path.read_text(encoding="utf-8"))
                executor_url = (data.get("executor_url") or "").strip()
                session_id = (data.get("session_id") or "").strip()
                if executor_url and session_id and _socket_reachable(executor_url):
                    stable = True
                    # Avoid attach races: require endpoint to stay alive briefly.
                    for _ in range(6):
                        if proc.poll() is not None or (not _socket_reachable(executor_url, timeout=0.5)):
                            stable = False
                            break
                        time.sleep(0.25)
                    if stable:
                        return data
            except Exception:
                pass

        time.sleep(0.25)

    raise TimeoutError(
        f"Timed out waiting for a fresh {SESSION_FILENAME} with a reachable geckodriver port."
    )


def run_open_powerbi(
    base_dir: Path,
    headless: bool = False,
    ready_timeout: int | None = None,
) -> subprocess.Popen:
    base_dir = base_dir.resolve()
    target = (base_dir / "Powerbi_Background" / "Open_PowerBi.py").resolve()
    if not target.exists():
        raise FileNotFoundError(f"Missing Open_PowerBi.py in {base_dir / 'Powerbi_Background'}")

    cmd = [sys.executable, str(target)]
    if headless:
        cmd.append("-headless")

    env = os.environ.copy()
    if ready_timeout is not None:
        env["POWERBI_READY_TIMEOUT"] = str(max(30, int(ready_timeout)))

    return _popen_group(cmd, cwd=str(base_dir), env=env)


def run_selected_scripts(base_dir: Path, scripts: List[Path]) -> None:
    base_dir = base_dir.resolve()
    for p in scripts:
        rel = p.relative_to(base_dir).as_posix()
        print(f"\n=== Running: {rel} ===")
        completed = subprocess.run([sys.executable, str(p.resolve())], cwd=str(base_dir), check=False)
        if completed.returncode != 0:
            raise RuntimeError(f"Script failed (exit {completed.returncode}): {rel}")


def _parse_args(argv: List[str] | None = None) -> argparse.Namespace:
    p = argparse.ArgumentParser(
        description=(
            "Select and run PowerBI attach scripts. Default behaviour launches a GUI; "
            "use -headless/--headless for command-line operation."
        )
    )
    p.add_argument(
        "-headless",
        "--headless",
        dest="headless",
        action="store_true",
        help=(
            "Run without the Tk GUI. Uses saved script selection if available; "
            "otherwise selects all non-hidden scripts (base folder + one level down)."
        ),
    )
    p.add_argument(
        "--keep-open",
        action="store_true",
        help="Keep Power BI open when finished (useful for chaining jobs).",
    )
    p.add_argument(
        "--timeout",
        type=int,
        default=180,
        help="Seconds to wait for Open_PowerBi to publish a reachable session (default: 180).",
    )
    p.add_argument(
        "--configure-selection",
        action="store_true",
        help="Open the script selector UI and save this as the default selection for future runs.",
    )
    return p.parse_args(argv)


def main(argv: List[str] | None = None) -> int:
    args = _parse_args(argv)
    base_dir = WORKSPACE_DIR
    available = get_displayable_scripts(
        base_dir=base_dir,
        this_file=Path(__file__),
        hide_script_names=sorted(HIDE_SCRIPT_NAMES),
        hide_script_name_contains=sorted(HIDE_SCRIPT_NAME_CONTAINS),
        hide_folders=sorted(HIDE_FOLDERS),
    )

    saved_rel, saved_keep_open = load_saved_selection(base_dir)

    if args.headless:
        selected = _resolve_saved_scripts(base_dir, available, saved_rel) if saved_rel else list(available)
        keep_open = bool(saved_keep_open) if saved_rel else bool(args.keep_open)
        cancelled = False

        if saved_rel:
            print("Headless mode: using saved script selection:")
            for pth in selected:
                print(" -", pth.relative_to(base_dir).as_posix())

            missing_count = len(saved_rel) - len(selected)
            if missing_count > 0:
                print(
                    f"Note: {missing_count} saved script(s) were not found. "
                    "Run with --configure-selection to update defaults."
                )
            if not selected:
                print("Headless mode: saved selection resolved to no runnable scripts.")
        else:
            if selected:
                print("Headless mode: no saved selection found; running all eligible scripts:")
                for pth in selected:
                    print(" -", pth.relative_to(base_dir).as_posix())
            else:
                print("Headless mode: no eligible scripts found to run.")
    else:
        if args.configure_selection or not saved_rel:
            selected, keep_open, cancelled = choose_scripts_gui(
                base_dir=base_dir,
                scripts=available,
                default_selected_rel=saved_rel,
                default_keep_open=saved_keep_open,
            )
            if not cancelled:
                save_selection(base_dir, selected, keep_open)
        else:
            selected = _resolve_saved_scripts(base_dir, available, saved_rel)
            keep_open = bool(saved_keep_open)
            cancelled = False
            print("Using saved script selection:")
            for pth in selected:
                print(" -", pth.relative_to(base_dir).as_posix())

            missing_count = len(saved_rel) - len(selected)
            if missing_count > 0:
                print(
                    f"Note: {missing_count} saved script(s) were not found. "
                    "Run with --configure-selection to update defaults."
                )

    if args.keep_open:
        keep_open = True

    if cancelled:
        return 0

    if (not selected) and (not keep_open):
        # Avoid launching Power BI just to immediately close it.
        msg = (
            "No scripts were selected.\n\n"
            "Either select scripts to run, or check 'Keep Power BI open when finished'."
        )
        if not args.headless and messagebox is not None:
            messagebox.showinfo("Nothing to run", msg)
        else:
            print(msg)
        return 0

    # Remove stale session file so attach scripts can't grab old dead ports.
    session_path = _pick_session_file(base_dir)
    if session_path.exists() and not os.environ.get("POWERBI_SESSION_FILE", "").strip():
        try:
            session_path.unlink()
        except Exception:
            pass

    proc: subprocess.Popen | None = None
    try:
        open_ready_timeout = max(30, int(args.timeout) - 15)
        startup_retries = max(1, int(os.environ.get("POWERBI_OPEN_STARTUP_RETRIES", "3")))

        for attempt in range(1, startup_retries + 1):
            proc = run_open_powerbi(
                base_dir,
                headless=bool(args.headless),
                ready_timeout=open_ready_timeout,
            )

            if not selected:
                break

            try:
                print("Waiting for Open_PowerBi to publish session info...")
                _wait_for_fresh_session(proc, base_dir, timeout=int(args.timeout))
                print("Session file detected and geckodriver port is reachable.")
                run_selected_scripts(base_dir, selected)
                break
            except RuntimeError as e:
                msg = str(e)
                early_exit = ("Open_PowerBi.py exited early" in msg)
                attach_failure = ("Script failed (exit 2):" in msg)
                retryable = early_exit or attach_failure
                if (not retryable) or (attempt >= startup_retries):
                    raise

                if attach_failure:
                    print(
                        "Attach script could not connect to session; restarting Open_PowerBi "
                        f"and retrying ({attempt}/{startup_retries})..."
                    )
                else:
                    print(
                        "Open_PowerBi exited before session handoff; retrying startup "
                        f"({attempt}/{startup_retries})..."
                    )

                _terminate_process_tree(proc)
                proc = None

                # Avoid attaching to stale session data after a failed attempt.
                if session_path.exists() and not os.environ.get("POWERBI_SESSION_FILE", "").strip():
                    try:
                        session_path.unlink()
                    except Exception:
                        pass

                time.sleep(min(2 * attempt, 8))

    except Exception as e:
        print(f"\nERROR: {e}\n")
        if (not args.headless) and (messagebox is not None):
            try:
                messagebox.showerror("PowerBI Selector Error", str(e))
            except Exception:
                pass
        return 1
    finally:
        if proc is not None and not keep_open:
            _terminate_process_tree(proc)

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

