"""Main CLI entry point for AiCippy.

Provides all command-line commands using Typer with Rich UI.
Features auto-update checks and 60-minute session timeout login prompts.
"""

from __future__ import annotations

import asyncio
import contextlib
import getpass
import json
import os
import re
import socket
import sys
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any, Final

if TYPE_CHECKING:
    from collections.abc import Callable

    from aicippy.auth.models import AuthResult
import base64
import importlib
import platform
import shutil
import subprocess
import time
import warnings

# Suppress all warnings (pip, deprecation, etc) in user-facing output
warnings.filterwarnings("ignore")
from datetime import UTC, datetime, timedelta

import typer
from rich.align import Align
from rich.console import Console
from rich.live import Live
from rich.markdown import Markdown
from rich.panel import Panel
from rich.progress import (
    BarColumn,
    Progress,
    SpinnerColumn,
    TextColumn,
)
from rich.prompt import Prompt
from rich.table import Table
from rich.text import Text

from aicippy import __version__
from aicippy.billing.models import PlanInfo

# Human verification
from aicippy.cli.human_verify import (
    HumanVerifier,
)
from aicippy.cli.themes import (
    BRAND_ERROR,
    BRAND_PRIMARY,
    BRAND_SECONDARY,
    BRAND_SUCCESS,
    BRAND_WARNING,
)
from aicippy.config import ModelName, get_settings

# Session and version management
from aicippy.installer import (
    SessionManager,
    check_latest_version,
    install_aicippy,
    perform_upgrade,
    upgrade_aicippy,
    verify_installation,
)
from aicippy.installer.dependency_manager import check_dependencies
from aicippy.installer.session_manager import SESSION_EXPIRED
from aicippy.utils.async_utils import run_sync
from aicippy.utils.correlation import CorrelationContext
from aicippy.utils.logging import get_logger, setup_logging
from aicippy.utils.network import check_cognito_reachable, check_pypi_reachable

# Optional imports: These modules may fail to import if optional dependencies
# (e.g., google-cloud-aiplatform, websockets, boto3) are not installed. Each is
# gracefully degraded to None so the CLI can still function for basic operations.
try:
    from aicippy.auth.cognito import CognitoAuth
except ImportError:
    CognitoAuth = None  # type: ignore[assignment]

try:
    from aicippy.cli.mascot import animate_welcome_compact
except ImportError:
    animate_welcome_compact = None  # type: ignore[assignment]

try:
    from aicippy.billing.plan_validator import PlanValidator
except ImportError:
    PlanValidator = None  # type: ignore[assignment]

try:
    from aicippy.platform import PlatformClient
except ImportError:
    PlatformClient = None  # type: ignore[assignment]

try:
    from aicippy.cli.interactive import start_interactive_session
except ImportError:
    start_interactive_session = None  # type: ignore[assignment]

try:
    from aicippy.knowledge.project_scanner import ProjectScanner
except ImportError:
    ProjectScanner = None  # type: ignore[assignment]

try:
    from aicippy.workspace.ref_folder import create_ref_folder
except ImportError:
    create_ref_folder = None  # type: ignore[assignment]

try:
    from aicippy.platform.tenant_context import TenantContext
except ImportError:
    TenantContext = None  # type: ignore[assignment]

try:
    from aicippy.agents.bedrock_agent import BedrockAgentClient
except ImportError:
    BedrockAgentClient = None  # type: ignore[assignment]

try:
    from aicippy.memory.workspace_memory import WorkspaceMemory
except ImportError:
    WorkspaceMemory = None  # type: ignore[assignment]

try:
    from aicippy.billing.credit_manager import CreditManager
except ImportError:
    CreditManager = None  # type: ignore[assignment]

try:
    from aicippy.agents.orchestrator import AgentOrchestrator
except ImportError:
    AgentOrchestrator = None  # type: ignore[assignment]

try:
    from aicippy.agents.status import get_agent_status
except ImportError:
    get_agent_status = None  # type: ignore[assignment]

try:
    from aicippy.agents.usage import get_usage_stats
except ImportError:
    get_usage_stats = None  # type: ignore[assignment]

try:
    from aicippy.auth.keychain import KeychainStorage
except ImportError:
    KeychainStorage = None  # type: ignore[assignment]

try:
    import boto3
except ImportError:
    boto3 = None  # type: ignore[assignment]

# Constants
UPDATE_CHECK_ENABLED: Final[bool] = True  # Auto-update on every app load
SESSION_CHECK_ENABLED: Final[bool] = True
HUMAN_VERIFICATION_ENABLED: Final[bool] = False  # Disabled — internal development

# Session timeout thresholds (minutes)
SESSION_THRESHOLD_STALE: Final[int] = 30
SESSION_THRESHOLD_CRITICAL: Final[int] = 10

def _abort_with_exit_code(code: int = 1) -> None:
    """Raise typer.Exit with the given code."""
    raise typer.Exit(code)


# Internal query detection — shared utility (single source of truth)
from aicippy.utils.input_safety import is_prompt_injection as _is_internal_query


def _detect_terminal_utf8() -> bool:
    """Detect whether the terminal supports UTF-8 encoding.

    Checks stdout encoding and the LANG/LC_ALL environment variables.
    Falls back to False if encoding cannot be determined.

    Returns:
        True if the terminal is UTF-8 capable, False otherwise.

    """
    # Check stdout encoding
    encoding = getattr(sys.stdout, "encoding", None) or ""
    if encoding.lower().replace("-", "") in ("utf8", "utf16", "utf32"):
        return True

    # Check environment variables (common on Linux/macOS)
    for env_var in ("LC_ALL", "LANG", "LC_CTYPE"):
        val = os.environ.get(env_var, "").lower()
        if "utf-8" in val or "utf8" in val:
            return True

    return False


def _is_ci_environment() -> bool:
    """Detect if running in a CI/CD environment.

    Checks common CI environment variables.

    Returns:
        True if running in CI, False otherwise.

    """
    ci_indicators = (
        "CI",
        "CONTINUOUS_INTEGRATION",
        "GITHUB_ACTIONS",
        "GITLAB_CI",
        "JENKINS_URL",
        "CIRCLECI",
        "TRAVIS",
        "BUILDKITE",
        "CODEBUILD_BUILD_ID",
    )
    return any(os.environ.get(var) for var in ci_indicators)


# Terminal capability detection
_TERMINAL_UTF8: Final[bool] = _detect_terminal_utf8()
_IS_CI: Final[bool] = _is_ci_environment()

# Initialize Typer app with Rich
app = typer.Typer(
    name="aicippy",
    help="Enterprise-grade multi-agent CLI for Vibe Coding",
    add_completion=True,
    rich_markup_mode="rich",
    no_args_is_help=False,
)

# Rich console for output - force_terminal=True in CI for consistent rendering
# Respect NO_COLOR (https://no-color.org/) even when force_terminal is used
console = Console(force_terminal=True, no_color=bool(os.environ.get("NO_COLOR"))) if _IS_CI else Console()
error_console = (
    Console(stderr=True, force_terminal=True, no_color=bool(os.environ.get("NO_COLOR")))
    if _IS_CI
    else Console(stderr=True)
)

# Logger
logger = get_logger(__name__)

# Email validation regex (RFC 5322 simplified) with length limit
_EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
_EMAIL_MAX_LENGTH: Final[int] = 254  # RFC 5321 max email address length

# Session manager instance (thread-safe singleton)
_session_manager: SessionManager | None = None
_session_manager_lock = threading.Lock()


def get_session_manager() -> SessionManager:
    """Get or create session manager singleton (thread-safe)."""
    global _session_manager  # noqa: PLW0603
    if _session_manager is None:
        with _session_manager_lock:
            if _session_manager is None:
                _session_manager = SessionManager()
    return _session_manager


# Cache for validated login context (avoids double plan validation)
_login_plan_info: Any | None = None  # PlanInfo from login flow
_login_platform_client: Any | None = None  # PlatformClient from login flow


def get_login_context() -> tuple[Any | None, Any | None]:
    """Get cached plan_info and platform_client from login flow."""
    return _login_plan_info, _login_platform_client


async def check_for_updates_async(silent: bool = True) -> bool:
    """Check for updates and optionally perform silent upgrade.

    Args:
        silent: If True, run silently without UI output.

    Returns:
        True if update was performed, False otherwise.

    """
    if not UPDATE_CHECK_ENABLED:
        return False

    try:
        version_info = await check_latest_version()

        if version_info.update_available:
            if silent:
                # Silent auto-upgrade

                success, _ = await perform_upgrade()
                return success
            # Show update notification (only when explicitly requested)
            console.print(
                Panel(
                    f"[yellow]Update available![/yellow]\n"
                    f"Current: {version_info.current}\n"
                    f"Latest: {version_info.latest}\n\n"
                    f"Run [bold cyan]aicippy upgrade[/bold cyan] to update.",
                    title="Update Available",
                    border_style="yellow",
                ),
            )
    except Exception as update_err:
        logger.warning("update_check_failed", error=str(update_err))
        return False
    else:
        return False


def check_for_updates(silent: bool = True) -> None:
    """Run synchronous wrapper for update check (silent by default)."""
    try:
        asyncio.run(check_for_updates_async(silent=silent))
    except Exception as sync_update_err:
        logger.warning("sync_update_check_failed", error=str(sync_update_err))


_auto_update_lock = threading.Lock()


def _perform_auto_update() -> None:
    """Kick off a background thread to check for and install updates.

    The update is completely silent -- no console output whatsoever.
    All diagnostics go through structlog only.  If an update is
    installed successfully it takes effect on the **next** launch;
    the current process is never re-exec'd.

    Guards:
    - ``_AICIPPY_UPDATED`` env var prevents repeated runs.
    - ``_auto_update_lock`` prevents concurrent pip invocations
      from parallel aicippy processes.
    """
    # Guard: skip if we already ran an update in this process tree
    if os.environ.get("_AICIPPY_UPDATED") == "1":
        return

    def _background_update() -> None:  # noqa: PLR0912

        # --- Pre-flight: is PyPI even reachable? ---
        try:

            if not check_pypi_reachable(timeout=3.0):
                logger.warning(
                    "auto_update_skipped_pypi_unreachable",
                )
                return
        except Exception as net_err:
            logger.warning(
                "auto_update_preflight_failed",
                error=str(net_err),
            )
            return

        # --- Acquire lock (non-blocking) to avoid concurrent pip ---
        acquired = _auto_update_lock.acquire(blocking=False)
        if not acquired:
            logger.warning(
                "auto_update_skipped_concurrent_update_in_progress",
            )
            return

        try:
            # --- Check latest version ---

            version_info = asyncio.run(check_latest_version())

            if not version_info.update_available or not version_info.latest:
                logger.info(
                    "auto_update_no_update_available",
                    current=version_info.current,
                    latest=version_info.latest,
                )
                return

            logger.info(
                "auto_update_starting",
                current=version_info.current,
                target=version_info.latest,
            )

            # --- Perform upgrade ---
            success, msg = asyncio.run(perform_upgrade(silent=True))

            if not success:
                logger.warning(
                    "auto_update_upgrade_failed",
                    message=msg,
                )
                return

            # --- Post-install verification via pip show ---
            try:
                verify = subprocess.run(  # noqa: S603
                    [
                        sys.executable,
                        "-m",
                        "pip",
                        "show",
                        "aicippy",
                    ],
                    capture_output=True,
                    text=True,
                    timeout=30,
                    stdin=subprocess.DEVNULL,
                    check=False,
                )
                installed_version: str | None = None
                if verify.returncode == 0:
                    for line in verify.stdout.splitlines():
                        if line.startswith("Version:"):
                            installed_version = line.split(":", 1)[1].strip()
                            break

                if installed_version and installed_version == version_info.latest:
                    logger.info(
                        "auto_update_verified",
                        installed=installed_version,
                    )
                else:
                    logger.warning(
                        "auto_update_verification_mismatch",
                        expected=version_info.latest,
                        installed=installed_version,
                    )
            except Exception as verify_err:
                logger.warning(
                    "auto_update_verification_failed",
                    error=str(verify_err),
                )

            # Mark env so child processes / quick re-launches skip
            os.environ["_AICIPPY_UPDATED"] = "1"

            logger.info(
                "auto_update_complete",
                new_version=version_info.latest,
                note="update takes effect on next launch",
            )

        except Exception as bg_err:
            logger.warning(
                "auto_update_background_failed",
                error=str(bg_err),
            )
        finally:
            _auto_update_lock.release()

    # Fire-and-forget daemon thread -- never blocks the main CLI
    thread = threading.Thread(
        target=_background_update,
        name="aicippy-auto-update",
        daemon=True,
    )
    thread.start()


def _force_latest_version() -> None:
    """Force-install the latest aicippy version on every launch.

    Blocks startup until the latest version is installed. After a
    successful upgrade, re-execs the process so the NEW version runs
    — the user always gets the latest production release.
    """
    # Guard: if we already re-exec'd after an upgrade, don't loop
    if os.environ.get("_AICIPPY_UPDATED") == "1":
        return

    try:

        if not check_pypi_reachable(timeout=3.0):
            return  # Offline — skip silently
    except Exception:
        return

    try:

        version_info = asyncio.run(check_latest_version())

        if not version_info.update_available or not version_info.latest:
            return  # Already on latest

        target_version = version_info.latest

        with Progress(
            SpinnerColumn(style=BRAND_PRIMARY),
            TextColumn(f"[{BRAND_PRIMARY}]{{task.description}}[/{BRAND_PRIMARY}]"),
            BarColumn(
                bar_width=40,
                complete_style=BRAND_PRIMARY,
                finished_style=BRAND_SUCCESS,
            ),
            TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
            console=console,
            transient=True,
        ) as progress:
            task = progress.add_task(
                f"Updating v{__version__} → v{target_version}",
                total=100,
            )

            progress.update(task, completed=10)

            upgrade_result = subprocess.run(  # noqa: S603
                [
                    sys.executable,
                    "-m",
                    "pip",
                    "install",
                    "--no-cache-dir",
                    "--quiet",
                    "--disable-pip-version-check",
                    "--no-warn-script-location",
                    f"aicippy=={target_version}",
                    "--index-url",
                    "https://pypi.org/simple/",
                ],
                capture_output=True,
                text=True,
                timeout=120,
                stdin=subprocess.DEVNULL,
                check=False,
            )

            if upgrade_result.returncode != 0:
                logger.warning(
                    "force_update_pip_failed",
                    returncode=upgrade_result.returncode,
                    stderr=upgrade_result.stderr.strip()[:200],
                )
                progress.update(task, completed=100, description="Update failed")
                return

            progress.update(task, completed=90, description="Verifying...")

            # Post-upgrade verification
            verify = subprocess.run(  # noqa: S603
                [sys.executable, "-m", "pip", "show", "aicippy"],
                capture_output=True,
                text=True,
                timeout=30,
                stdin=subprocess.DEVNULL,
                check=False,
            )
            installed_version: str | None = None
            if verify.returncode == 0:
                for line in verify.stdout.splitlines():
                    if line.startswith("Version:"):
                        installed_version = line.split(":", 1)[1].strip()
                        break

            if installed_version != target_version:
                logger.warning(
                    "force_update_verification_mismatch",
                    expected=target_version,
                    actual=installed_version,
                )
                progress.update(task, completed=100, description="Verify failed")
                return

            progress.update(task, completed=100, description="Updated")

        console.print(
            f"  [#10b981]\u2713[/#10b981] [bold]Updated to v{target_version}[/bold]  — relaunching...",
        )

        # Re-exec the process with the NEW version.
        # Detect if invoked via console_script or python -m.

        os.environ["_AICIPPY_UPDATED"] = "1"
        aicippy_bin = shutil.which("aicippy")
        if aicippy_bin and not sys.argv[0].endswith(".py"):
            # Console script entry — re-exec via the script
            os.execv(aicippy_bin, [aicippy_bin, *sys.argv[1:]])  # noqa: S606
        else:
            # Module invocation — re-exec via python -m
            os.execv(sys.executable, [sys.executable, "-m", "aicippy", *sys.argv[1:]])  # noqa: S606

    except Exception as e:
        logger.warning("force_update_failed", error=str(e))
        _perform_auto_update()


def _ensure_dependencies() -> None:  # noqa: PLR0912, PLR0915
    """Check and install missing or outdated dependencies with progress.

    Runs silently when all dependencies are satisfied. Shows a Rich
    progress bar only when packages actually need installing. Failures
    are logged at WARNING and never block app startup.
    """
    try:

        # ------------------------------------------------------------------
        # 1. Pre-flight: verify pip itself is functional
        # ------------------------------------------------------------------
        try:
            pip_ver = subprocess.run(  # noqa: S603
                [sys.executable, "-m", "pip", "--version"],
                capture_output=True,
                text=True,
                timeout=30,
                stdin=subprocess.DEVNULL,
                check=False,
            )
            if pip_ver.returncode != 0:
                logger.warning(
                    "pip_preflight_failed",
                    returncode=pip_ver.returncode,
                    stderr=pip_ver.stderr.strip(),
                )
                return
        except Exception as pip_check_err:
            logger.warning(
                "pip_preflight_error",
                error=str(pip_check_err),
            )
            return

        # ------------------------------------------------------------------
        # 2. Check which packages are missing / outdated
        # ------------------------------------------------------------------
        dep_check = check_dependencies()
        if dep_check.all_satisfied:
            return  # Nothing to do

        packages_needed = [pkg for pkg in dep_check.packages if not pkg.is_installed or pkg.needs_upgrade]
        if not packages_needed:
            return

        # ------------------------------------------------------------------
        # 3. Pre-flight: verify PyPI is reachable before installing
        # ------------------------------------------------------------------

        if not check_pypi_reachable():
            logger.warning(
                "pypi_unreachable_skipping_dependency_install",
                packages_needed=len(packages_needed),
            )
            return

        # ------------------------------------------------------------------
        # 4. Install with honest progress bar
        # ------------------------------------------------------------------
        total_steps = len(packages_needed) + 1  # +1 for pip upgrade

        with Progress(
            SpinnerColumn(style=BRAND_PRIMARY),
            TextColumn(f"[{BRAND_PRIMARY}]{{task.description}}[/{BRAND_PRIMARY}]"),
            BarColumn(
                bar_width=40,
                complete_style=BRAND_PRIMARY,
                finished_style=BRAND_SUCCESS,
            ),
            TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
            console=console,
            transient=True,
        ) as progress:
            task = progress.add_task(
                "Preparing environment...",
                total=total_steps,
            )

            # --- Step A: upgrade pip ---
            base_flags = [
                "--quiet",
                "--disable-pip-version-check",
                "--no-warn-script-location",
            ]
            try:
                pip_up = subprocess.run(  # noqa: S603
                    [
                        sys.executable,
                        "-m",
                        "pip",
                        "install",
                        "--upgrade",
                        "pip",
                        *base_flags,
                    ],
                    capture_output=True,
                    text=True,
                    timeout=120,
                    stdin=subprocess.DEVNULL,
                    check=False,
                )
                if pip_up.returncode != 0:
                    logger.warning(
                        "pip_self_upgrade_failed",
                        returncode=pip_up.returncode,
                        stderr=pip_up.stderr.strip(),
                    )
            except subprocess.TimeoutExpired:
                logger.warning("pip_self_upgrade_timed_out")
            except Exception as pip_err:
                logger.warning(
                    "pip_self_upgrade_error",
                    error=str(pip_err),
                )

            progress.update(
                task,
                advance=1,
                description="Installing dependencies...",
            )

            # --- Step B: install needed packages in one pip call ---
            package_specs = [f"{pkg.name}>={pkg.required_version}" for pkg in packages_needed]
            install_ok = False
            try:
                install_result = subprocess.run(  # noqa: S603
                    [
                        sys.executable,
                        "-m",
                        "pip",
                        "install",
                        "--upgrade",
                        *base_flags,
                        *package_specs,
                    ],
                    capture_output=True,
                    text=True,
                    timeout=600,
                    stdin=subprocess.DEVNULL,
                    check=False,
                )
                if install_result.returncode != 0:
                    logger.warning(
                        "dependency_install_failed",
                        returncode=install_result.returncode,
                        stderr=install_result.stderr.strip(),
                    )
                else:
                    install_ok = True
            except subprocess.TimeoutExpired:
                logger.warning(
                    "dependency_install_timed_out",
                    timeout_seconds=600,
                )
            except Exception as dep_err:
                logger.warning(
                    "dependency_install_error",
                    error=str(dep_err),
                )

            # Advance progress honestly based on outcome
            if install_ok:
                progress.update(
                    task,
                    completed=total_steps,
                    description="Environment ready",
                )
            else:
                # Don't pretend it succeeded -- leave progress incomplete
                progress.update(
                    task,
                    description="Install incomplete",
                )

        # ------------------------------------------------------------------
        # 5. Post-install verification
        # ------------------------------------------------------------------
        if install_ok:
            verify = check_dependencies()
            if not verify.all_satisfied:
                still_missing = [pkg.name for pkg in verify.packages if not pkg.is_installed or pkg.needs_upgrade]
                logger.warning(
                    "post_install_verification_failed",
                    still_missing=still_missing,
                )

    except Exception as ensure_err:
        # Never block startup, but log at WARNING
        logger.warning(
            "ensure_dependencies_failed",
            error=str(ensure_err),
        )


def check_session_and_prompt_login() -> bool:  # noqa: PLR0911, PLR0912, PLR0915
    """Check if session is valid, prompt for login if expired.

    Behavior:
    - Valid session: silently continue (no output).
    - Recently expired session (< 30 min): show brief message, attempt
      token refresh before falling back to full interactive login.
    - Invalid/missing session: proceed to full interactive login flow.

    Returns:
        True if session is valid or user successfully logged in.

    """
    if not SESSION_CHECK_ENABLED:
        return True

    try:
        session_mgr = get_session_manager()
        validation = session_mgr.validate()

        if not validation.needs_login:
            # Session is valid - silently refresh activity
            session_mgr.touch()
            return True

        # --- Session requires login ---
        # Check if the session expired recently (within 30 minutes)
        if validation.status == SESSION_EXPIRED and validation.session is not None:
            now = datetime.now(UTC)
            time_since_expiry = now - validation.session.expires_at
            recently_expired = timedelta(0) <= time_since_expiry < timedelta(minutes=30)

            if recently_expired:
                # Pre-flight: check if Cognito is reachable before
                # attempting token refresh (avoid wasting time on a
                # doomed network call).
                cognito_reachable = False
                try:
                    auth_host = "auth.aivibe.cloud"
                    conn = socket.create_connection(
                        (auth_host, 443),
                        timeout=3,
                    )
                    conn.close()
                    cognito_reachable = True
                except (OSError, TimeoutError) as net_err:
                    logger.warning(
                        "auth_preflight_unreachable",
                        host=auth_host if "auth_host" in dir() else "unknown",
                        error=str(net_err),
                    )

                if not cognito_reachable:
                    console.print(
                        f"\n[{BRAND_WARNING}]"
                        "  Cannot reach authentication service. "
                        "Check your network and try again."
                        f"[/{BRAND_WARNING}]\n",
                    )
                    return perform_interactive_login()

                # Attempt silent token refresh
                refresh_failure_reason: str | None = None
                try:
                    if CognitoAuth is not None:
                        auth = CognitoAuth()
                        tokens = auth.get_current_tokens()

                        if tokens is not None and not tokens.is_expired():
                            # Tokens are still valid -- re-create the session
                            # (refresh_session rejects expired sessions, so
                            # we re-login using the expired session's info)
                            msg = f"[{BRAND_PRIMARY}]  Session expired. Re-authenticating...[/{BRAND_PRIMARY}]"
                            with console.status(
                                msg,
                                spinner="dots",
                                spinner_style=BRAND_PRIMARY,
                            ):
                                expired_session = validation.session
                                success, _ = session_mgr.login(
                                    user_id=expired_session.user_id,
                                    username=expired_session.username,
                                    email=expired_session.email,
                                    ip_address=expired_session.ip_address,
                                    metadata=expired_session.metadata,
                                )

                            if success:
                                return True
                            refresh_failure_reason = "Session refresh failed after token validation."
                        else:
                            refresh_failure_reason = "Stored tokens have expired. Please log in with your credentials."
                    else:
                        refresh_failure_reason = "Authentication module not available."
                except OSError as refresh_err:
                    refresh_failure_reason = f"Network error during refresh: {refresh_err}"
                    logger.warning(
                        "token_refresh_network_error",
                        error=str(refresh_err),
                    )
                except Exception as refresh_err:
                    error_str = str(refresh_err).lower()
                    if "expired" in error_str or "invalid" in error_str:
                        refresh_failure_reason = "Authentication token is invalid or expired."
                    else:
                        refresh_failure_reason = f"Token refresh error: {refresh_err}"
                    logger.warning(
                        "token_refresh_failed_during_session_check",
                        error=str(refresh_err),
                    )

                # Token refresh did not succeed -- show reason
                reason_msg = refresh_failure_reason or "Session expired."
                expired_msg = f"\n[{BRAND_WARNING}]  {reason_msg} Please log in again.[/{BRAND_WARNING}]\n"
                console.print(expired_msg)
                return perform_interactive_login()

        # Session invalid or missing - trigger full login directly
        return perform_interactive_login()

    except Exception as e:
        logger.warning("session_check_failed", error=str(e))
        return False  # Block access on session check failure


def _show_login_progress(
    console: Console,
    steps: list[tuple[str, str]],
) -> None:
    """Show animated step-by-step login progress using Rich Live display.

    Each step transitions from pending to running to complete:
      pending  ->  running  ->  complete
        (dim)    (spinner)    (green check)

    Falls back to ASCII symbols when the terminal does not support UTF-8.

    Args:
        console: Rich console instance.
        steps: List of (step_key, step_label) tuples. Each step is shown
               sequentially with animation.

    """
    # Select symbols based on terminal UTF-8 support
    sym_pending = "  o " if not _TERMINAL_UTF8 else "  \u25cb "
    sym_running = "  > " if not _TERMINAL_UTF8 else "  \u25d0 "
    sym_complete = "  * " if not _TERMINAL_UTF8 else "  \u2713 "

    step_states: list[str] = ["pending"] * len(steps)

    def _render_steps() -> Text:
        """Render all steps with their current visual state."""
        output = Text()
        for idx, (_, label) in enumerate(steps):
            state = step_states[idx]
            if state == "pending":
                output.append(sym_pending, style="dim")
                output.append(label, style="dim")
            elif state == "running":
                output.append(sym_running, style=f"bold {BRAND_PRIMARY}")
                output.append(label, style=BRAND_PRIMARY)
                output.append(" ...", style="dim")
            elif state == "complete":
                output.append(sym_complete, style=f"bold {BRAND_SUCCESS}")
                output.append(label, style=BRAND_SUCCESS)
            if idx < len(steps) - 1:
                output.append("\n")
        return output

    with Live(
        _render_steps(),
        console=console,
        refresh_per_second=8,
        transient=True,
    ) as live:
        for i in range(len(steps)):
            # Mark current step as running
            step_states[i] = "running"
            live.update(_render_steps())
            time.sleep(0.4)

            # Mark current step as complete
            step_states[i] = "complete"
            live.update(_render_steps())
            time.sleep(0.15)

    # Print final state (non-transient) so it persists
    console.print(_render_steps())


def _validate_tokens_after_auth(result: AuthResult, console: Console) -> None:
    """Sanity check tokens after successful auth."""
    if not result.tokens or not result.tokens.access_token:
        console.print(f"[{BRAND_ERROR}]  No access token returned.[/{BRAND_ERROR}]")
        _abort_with_exit_code(1)

    try:
        token = result.tokens.access_token if result.tokens else ""
        header_segment = token.split(".")[0]
        padded = header_segment + "=" * (4 - len(header_segment) % 4)
        header_bytes = base64.urlsafe_b64decode(padded)
        header_data = json.loads(header_bytes)
        if not isinstance(header_data, dict) or "alg" not in header_data:
            raise ValueError("Missing_alg")  # noqa: TRY301
    except Exception as e:
        logger.warning("token_validation_failed", error=str(e))
        console.print(f"[{BRAND_ERROR}]  Malformed token. Please login again.[/{BRAND_ERROR}]")
        _abort_with_exit_code(1)


def _validate_plan_after_auth(
    user_email: str, access_token: str, console: Console,
) -> tuple[object | None, dict[str, object]]:
    """Validate user plan and return plan info and metadata."""
    plan_info = None
    plan_metadata = {}
    settings = get_settings()

    with console.status(
        f"[{BRAND_PRIMARY}]  Validating plan...[/{BRAND_PRIMARY}]",
        spinner="dots",
        spinner_style=BRAND_PRIMARY,
    ):
        try:
            platform_client = None
            if PlatformClient is not None:
                platform_client = PlatformClient(api_url=settings.platform_api_url, token=access_token)

            if PlanValidator is not None:
                validator = PlanValidator(platform_client=platform_client)
                plan_info = validator.validate(user_email)

                if not plan_info.is_valid_for_aicippy:
                    logger.info("plan_status", plan=plan_info.plan, reason=plan_info.denial_reason)

                plan_metadata = {
                    "plan": plan_info.plan,
                    "credits": plan_info.credits_remaining,
                    "is_admin": plan_info.is_admin,
                    "tenant_id": plan_info.tenant_id,
                    "platform_api_url": settings.platform_api_url,
                }
                global _login_plan_info, _login_platform_client  # noqa: PLW0603
                _login_plan_info = plan_info
                _login_platform_client = platform_client
            else:
                logger.warning("plan_validator_unavailable")
        except typer.Exit:
            raise
        except Exception as e:
            logger.warning("plan_validation_failed", error=str(e))
            plan_metadata = {"plan": "unverified"}

    return plan_info, plan_metadata


def _show_login_success_summary(user_email: str, plan_info: PlanInfo | None, username: str) -> None:
    """Show login success panel and mascot."""
    _check = "  * " if not _TERMINAL_UTF8 else "  \u2713 "
    summary = Text()
    summary.append(f"{_check}Authenticated as {user_email}\n", style=BRAND_SUCCESS)
    if plan_info:
        summary.append(
            f"{_check}Plan: {plan_info.plan.upper()} ({plan_info.credits_remaining} credits)\n",
            style=BRAND_SUCCESS,
        )
    summary.append(f"{_check}Workspace ready\n", style=BRAND_SUCCESS)
    summary.append("  ↓ Live agent activity below", style="dim italic")

    console.print(Panel(Align.center(summary), title=f"[bold {BRAND_PRIMARY}] AiCippy Workspace [/]", border_style=BRAND_PRIMARY, padding=(1, 3)))  # noqa: E501
    console.print()

    if _TERMINAL_UTF8 and animate_welcome_compact is not None:
        try:
            run_sync(animate_welcome_compact(console, username, user_email=user_email))
        except Exception:
            logger.debug("welcome_animation_failed", exc_info=True)


def _complete_login_after_auth(  # noqa: PLR0912, PLR0915
    result: AuthResult,
    target_console: Console,
) -> bool:
    """Complete the login flow after successful authentication.

    Shared post-auth logic for all login methods (email+password, OTP).
    Performs token validation, plan validation, session creation, and
    displays the success summary.

    Args:
        result: Successful AuthResult with tokens.
        target_console: Rich console for output.

    Returns:
        True if session was created successfully.

    Raises:
        typer.Exit: On plan validation failure or session creation failure.

    """
    session_mgr = get_session_manager()
    user_email = result.user_email or ""
    username = user_email.split("@")[0] if user_email else "user"

    # === POST-AUTH TOKEN VALIDATION ===
    # Decode JWT header to verify tokens are well-formed before
    # proceeding. We do NOT perform full JWKS signature validation
    # here -- just structural sanity.
    if result.tokens and result.tokens.access_token:
        try:
            token = result.tokens.access_token if result.tokens else ""
            header_segment = token.split(".")[0]
            # Add padding for base64url
            padded = header_segment + "=" * (4 - len(header_segment) % 4)
            header_bytes = base64.urlsafe_b64decode(padded)
            header_data = json.loads(header_bytes)
            if not isinstance(header_data, dict) or "alg" not in header_data:
                target_console.print(
                    f"[{BRAND_ERROR}]  Received malformed"
                    " authentication token (missing algorithm"
                    " in JWT header). Please try logging in"
                    f" again.[/{BRAND_ERROR}]",
                )
                _abort_with_exit_code(1)
        except (IndexError, ValueError, UnicodeDecodeError) as tok_err:
            logger.warning(
                "post_auth_token_validation_failed",
                error=str(tok_err),
            )
            target_console.print(
                f"[{BRAND_ERROR}]  Received malformed"
                " authentication token. Please try logging"
                f" in again.[/{BRAND_ERROR}]",
            )
            raise typer.Exit(1) from tok_err
    elif not result.tokens or not result.tokens.access_token:
        target_console.print(
            f"[{BRAND_ERROR}]  Authentication succeeded but no"
            " access token was returned. Please try"
            f" again.[/{BRAND_ERROR}]",
        )
        _abort_with_exit_code(1)

    # === PLAN VALIDATION GATE ===
    global _login_plan_info, _login_platform_client  # noqa: PLW0603
    plan_info = None
    plan_metadata: dict[str, object] = {}

    with target_console.status(
        f"[{BRAND_PRIMARY}]  Validating plan...[/{BRAND_PRIMARY}]",
        spinner="dots",
        spinner_style=BRAND_PRIMARY,
    ):
        try:
            settings = get_settings()
            platform_client = None
            try:
                if PlatformClient is not None and result.tokens and result.tokens.access_token:
                    platform_client = PlatformClient(
                        api_url=settings.platform_api_url,
                        token=result.tokens.access_token,
                    )
            except Exception as platform_err:
                logger.warning(
                    "platform_client_init_failed",
                    error=str(platform_err),
                )

            if PlanValidator is not None:
                validator = PlanValidator(
                    platform_client=platform_client,
                )
                plan_info = validator.validate(user_email)

                if not plan_info.is_valid_for_aicippy:
                    logger.info("plan_status", plan=plan_info.plan, reason=plan_info.denial_reason)

                plan_metadata = {
                    "plan": plan_info.plan,
                    "credits": plan_info.credits_remaining,
                    "is_admin": plan_info.is_admin,
                    "tenant_id": plan_info.tenant_id,
                    "platform_api_url": (settings.platform_api_url if platform_client else ""),
                    "app_api_url": (settings.app_api_url if platform_client else ""),
                }

                # Cache for interactive session to avoid
                # re-validation
                _login_plan_info = plan_info
                _login_platform_client = platform_client
            else:
                logger.warning("plan_validator_not_available")
                target_console.print(
                    f"[{BRAND_WARNING}]  Plan verification unavailable. Running in limited mode.[/{BRAND_WARNING}]",
                )

        except typer.Exit:
            raise
        except Exception as plan_err:
            # Plan backend unavailable -- allow login with
            # degraded mode rather than blocking authenticated
            # users entirely.
            logger.warning(
                "plan_validation_failed_proceeding_degraded",
                error=str(plan_err),
            )
            target_console.print(
                f"[{BRAND_WARNING}]  Plan verification unavailable. Running in limited mode.[/{BRAND_WARNING}]",
            )
            plan_metadata = {
                "plan": "unverified",
                "credits": 0,
                "is_admin": False,
                "tenant_id": "",
                "platform_api_url": "",
                "app_api_url": "",
            }

            _login_plan_info = None
            _login_platform_client = None

    # --- Setting up workspace with spinner ---
    with target_console.status(
        f"[{BRAND_PRIMARY}]  Setting up workspace...[/{BRAND_PRIMARY}]",
        spinner="dots",
        spinner_style=BRAND_PRIMARY,
    ):
        login_success, _ = session_mgr.login(
            user_id=result.user_id or "unknown",
            username=username,
            email=user_email,
            metadata=plan_metadata,
        )

    if login_success:
        # === SESSION WRITE VERIFICATION ===
        # Verify the session was actually persisted to disk.
        post_login_validation = session_mgr.validate()
        if post_login_validation.needs_login:
            logger.warning(
                "session_write_verification_failed",
                status=post_login_validation.status,
                message=post_login_validation.message,
            )
            target_console.print(
                f"[{BRAND_WARNING}]  Warning: Session may"
                " not have persisted correctly. If you"
                " experience issues, run 'aicippy login'"
                f" again.[/{BRAND_WARNING}]",
            )

        try:
            _show_login_progress(
                target_console,
                [
                    ("auth", "Authenticated"),
                    ("plan", "Plan validated"),
                    ("workspace", "Workspace ready"),
                ],
            )
        except Exception as anim_err:
            logger.warning(
                "login_progress_animation_failed",
                error=str(anim_err),
            )

        target_console.print()

        _check = "  * " if not _TERMINAL_UTF8 else "  \u2713 "
        summary = Text()
        summary.append(_check, style=f"bold {BRAND_SUCCESS}")
        summary.append(
            "Authenticated as ",
            style=BRAND_SUCCESS,
        )
        summary.append(
            user_email,
            style=f"bold {BRAND_SUCCESS}",
        )
        summary.append("\n")

        if plan_info is not None:
            summary.append(
                _check,
                style=f"bold {BRAND_SUCCESS}",
            )
            summary.append("Plan: ", style=BRAND_SUCCESS)
            summary.append(
                f"{plan_info.plan.upper()} ({plan_info.credits_remaining}/{plan_info.credits_total} credits)",
                style=BRAND_SUCCESS,
            )
            summary.append("\n")

        summary.append(_check, style=f"bold {BRAND_SUCCESS}")
        summary.append("Workspace ready", style=BRAND_SUCCESS)
        summary.append("\n")
        summary.append("  ↓ ", style=f"bold {BRAND_PRIMARY}")
        summary.append("Live agent activity below", style="dim italic")

        target_console.print(
            Panel(
                Align.center(summary),
                title=f"[bold {BRAND_PRIMARY}]  AiCippy Workspace  [/bold {BRAND_PRIMARY}]",
                subtitle=f"[dim]v{__version__}[/dim]",
                border_style=BRAND_PRIMARY,
                padding=(1, 3),
            ),
        )
        target_console.print()

        try:
            if _TERMINAL_UTF8 and animate_welcome_compact is not None:
                run_sync(
                    animate_welcome_compact(
                        target_console,
                        username,
                        user_email=user_email,
                    ),
                )
            else:
                target_console.print(
                    Panel(
                        f"Welcome, {username}! Let's build something amazing!",
                        title="AiCippy",
                        border_style=BRAND_PRIMARY,
                    ),
                )
        except Exception as mascot_err:
            logger.warning(
                "mascot_animation_failed",
                error=str(mascot_err),
            )

        return True

    target_console.print(
        f"[{BRAND_ERROR}]  Failed to create session. Please try again.[/{BRAND_ERROR}]",
    )
    _abort_with_exit_code(1)
    return False


def _initiate_otp(auth: CognitoAuth, email: str) -> AuthResult | None:
    """Initiate OTP via Cognito."""
    with console.status(
        f"[{BRAND_PRIMARY}]  Sending OTP to {email}...[/{BRAND_PRIMARY}]",
        spinner="dots",
        spinner_style=BRAND_PRIMARY,
    ):
        try:
            return run_sync(auth.otp_login(email))
        except Exception as e:
            error_str = str(e).lower()
            if any(x in error_str for x in ["too many", "rate limit", "toomanyrequests"]):
                console.print(f"[{BRAND_ERROR}]  Too many requests. Wait 60s.[/{BRAND_ERROR}]")
            elif any(x in error_str for x in ["timeout", "connection", "network"]):
                console.print(f"[{BRAND_ERROR}]  Network error. Check connection.[/{BRAND_ERROR}]")
            else:
                console.print(f"[{BRAND_ERROR}]  OTP request failed: {e}[/{BRAND_ERROR}]")
            return None


def _verify_otp_loop(auth: CognitoAuth, email: str, session_token: str) -> bool:
    """Prompt for OTP and verify with 3 attempts."""
    for attempt in range(1, 4):
        otp_code = Prompt.ask(f"[{BRAND_PRIMARY}]  Enter 6-digit OTP code[/{BRAND_PRIMARY}]")
        if not otp_code or not otp_code.strip():
            console.print(f"[{BRAND_ERROR}]  Cancelled.[/{BRAND_ERROR}]")
            _abort_with_exit_code(1)

        otp_code = otp_code.strip()
        if not re.fullmatch(r"\d{6}", otp_code):
            console.print(f"[{BRAND_ERROR}]  Invalid format.[/{BRAND_ERROR}]")
            continue

        with console.status(
            f"[{BRAND_PRIMARY}]  Verifying...[/{BRAND_PRIMARY}]",
            spinner="dots",
            spinner_style=BRAND_PRIMARY,
        ):
            try:
                result = run_sync(auth.verify_otp(email, otp_code, session_token))
            except Exception as e:
                console.print(f"[{BRAND_ERROR}]  Verification failed: {e}[/{BRAND_ERROR}]")
                return False

        if result and result.success:
            return _complete_login_after_auth(result, console)

        if result and result.error:
            if any(x in result.error.lower() for x in ["too many", "rate limit"]):
                console.print(f"[{BRAND_ERROR}]  Rate limited.[/{BRAND_ERROR}]")
                return False
            console.print(f"[{BRAND_ERROR}]  {result.error}[/{BRAND_ERROR}]")

    return False


def _perform_otp_login_flow() -> bool:  # noqa: PLR0911
    """Run the Email OTP login flow."""
    if not check_cognito_reachable():
        console.print(f"[{BRAND_ERROR}]  Cannot reach auth server.[/{BRAND_ERROR}]")
        return False

    email = Prompt.ask(f"[{BRAND_PRIMARY}]  Email[/{BRAND_PRIMARY}]")
    if not email or not email.strip():
        console.print(f"[{BRAND_ERROR}]  Cancelled.[/{BRAND_ERROR}]")
        _abort_with_exit_code(1)

    email = email.strip().lower()
    if len(email) > _EMAIL_MAX_LENGTH or not _EMAIL_REGEX.match(email):
        console.print(f"[{BRAND_ERROR}]  Invalid email format.[/{BRAND_ERROR}]")
        return False

    if CognitoAuth is None:
        console.print(f"[{BRAND_ERROR}]  Auth module unavailable.[/{BRAND_ERROR}]")
        return False

    auth = CognitoAuth()
    try:
        otp_result = _initiate_otp(auth, email)
        if otp_result is None:
            return False

        if otp_result.success:
            return _complete_login_after_auth(otp_result, console)

        session_token = getattr(otp_result, "session", None)
        if not session_token or getattr(otp_result, "user_email", None) is None:
            error_msg = getattr(otp_result, "error", None) or "OTP initiation failed."
            console.print(f"[{BRAND_ERROR}]  {error_msg}[/{BRAND_ERROR}]")
            return False

        console.print(f"[{BRAND_SUCCESS}]  OTP sent to {email}. Check inbox.[/{BRAND_SUCCESS}]")
        return _verify_otp_loop(auth, email, session_token)

    finally:
        try:
            run_sync(auth.close())
        except Exception:
            logger.debug("auth_close_failed", exc_info=True)


def _get_login_choice() -> str:
    """Show login menu and get user choice."""
    if _pipe_mode:
        choice = input("AICIPPY_CHOICE [1]: ").strip() or "1"
        return choice if choice in ("1", "2") else "1"

    login_menu = Table(show_header=False, show_edge=True, box=None, padding=(0, 2))
    login_menu.add_column("option", style=f"bold {BRAND_PRIMARY}")
    login_menu.add_column("description")
    login_menu.add_row("1.", "Email + Password")
    login_menu.add_row("2.", "Email OTP (verification code)")

    console.print(
        Panel(
            login_menu,
            title=(f"[bold {BRAND_PRIMARY}]Choose login method[/bold {BRAND_PRIMARY}]"),
            border_style=BRAND_PRIMARY,
            padding=(1, 2),
        ),
    )
    return Prompt.ask(
        f"[{BRAND_PRIMARY}]  Enter choice[/{BRAND_PRIMARY}]",
        choices=["1", "2"],
        default="1",
    )


def _perform_password_login_flow(auth: CognitoAuth) -> bool:  # noqa: PLR0912, PLR0915
    """Run the Email + Password login flow."""
    _login_attempt_count = 0
    _max_login_attempts = 3

    while _login_attempt_count < _max_login_attempts:
        _login_attempt_count += 1
        email = input("AICIPPY_EMAIL: ") if _pipe_mode else Prompt.ask(f"[{BRAND_PRIMARY}]  Email[/{BRAND_PRIMARY}]")

        if not email or not email.strip():
            console.print(f"[{BRAND_ERROR}]  Cancelled.[/{BRAND_ERROR}]")
            _abort_with_exit_code(1)

        email = email.strip().lower()
        if len(email) > _EMAIL_MAX_LENGTH or not _EMAIL_REGEX.match(email):
            console.print(f"[{BRAND_ERROR}]  Invalid email format.[/{BRAND_ERROR}]")
            if _login_attempt_count < _max_login_attempts:
                continue
            _abort_with_exit_code(1)

        password = None
        if _pipe_mode:
            password = input("AICIPPY_PASSWORD: ")
        else:
            try:
                password = Prompt.ask(f"[{BRAND_PRIMARY}]  Password[/{BRAND_PRIMARY}]", password=True)
            except Exception:
                if getpass is not None:
                    with contextlib.suppress(EOFError, KeyboardInterrupt):
                        password = getpass.getpass("  Password: ")

        if not password:
            console.print(f"[{BRAND_ERROR}]  Cancelled.[/{BRAND_ERROR}]")
            _abort_with_exit_code(1)

        console.print()
        result = None
        auth_error_friendly = None

        with console.status(
            f"[{BRAND_PRIMARY}]  Authenticating...[/{BRAND_PRIMARY}]",
            spinner="dots",
            spinner_style=BRAND_PRIMARY,
        ):
            try:
                result = run_sync(auth.login(email, str(password)))  # type: ignore[attr-defined]  # CognitoAuth optionally imported
            except Exception as net_err:
                error_str = str(net_err).lower()
                if any(x in error_str for x in ["too many", "rate limit", "toomanyrequests"]):
                    auth_error_friendly = "Too many requests. Please wait 60 seconds."
                elif "timeout" in error_str:
                    auth_error_friendly = "Connection timed out."
                elif "connection" in error_str or "network" in error_str:
                    auth_error_friendly = "Network error. Check your connection."
                else:
                    auth_error_friendly = f"Authentication error: {net_err}"

        if auth_error_friendly:
            console.print(f"[{BRAND_ERROR}]  {auth_error_friendly}[/{BRAND_ERROR}]")
            if _login_attempt_count < _max_login_attempts:
                continue
            _abort_with_exit_code(1)

        if result and result.success:
            return _complete_login_after_auth(result, console)

        if result:
            error_msg = result.error or "Login failed"
            error_lower = error_msg.lower()
            if any(x in error_lower for x in ["too many", "rate limit", "toomanyrequests"]):
                console.print(f"[{BRAND_ERROR}]  Too many requests.[/{BRAND_ERROR}]")
                _abort_with_exit_code(1)

            if "invalid email or password" in error_lower:
                remaining = _max_login_attempts - _login_attempt_count
                console.print(f"[{BRAND_ERROR}]  Incorrect email or password.[/{BRAND_ERROR}]")
                if remaining > 0:
                    console.print(f"[dim]  {remaining} attempt(s) remaining.[/dim]")
                    continue
            else:
                console.print(f"[{BRAND_ERROR}]  {error_msg}[/{BRAND_ERROR}]")

        if _login_attempt_count >= _max_login_attempts:
            console.print(f"[{BRAND_ERROR}]  Maximum attempts reached.[/{BRAND_ERROR}]")
            _abort_with_exit_code(1)

    return False


def perform_interactive_login() -> bool:
    """Perform interactive login via direct Cognito auth."""
    try:
        if not check_cognito_reachable():
            console.print(f"[{BRAND_ERROR}]  Cannot reach auth server.[/{BRAND_ERROR}]")
            _abort_with_exit_code(1)

        header_text = Text()
        header_text.append("AiCippy", style=f"bold {BRAND_PRIMARY}")
        header_text.append(f"  v{__version__}", style="dim")
        header_text.append("\n")
        header_text.append("Enterprise Multi-Agent CLI", style=f"italic {BRAND_SECONDARY}")

        console.print()
        console.print(Panel(Align.center(header_text), border_style=BRAND_PRIMARY, padding=(1, 4)))
        console.print()

        while True:
            choice = _get_login_choice()
            if choice == "2":
                if _perform_otp_login_flow():
                    return True
                console.print()
                continue
            break

        if CognitoAuth is None:
            console.print(f"[{BRAND_ERROR}]  Auth module unavailable.[/{BRAND_ERROR}]")
            _abort_with_exit_code(1)

        auth = CognitoAuth()
        try:
            return _perform_password_login_flow(auth)
        finally:
            try:
                run_sync(auth.close())
            except Exception:
                logger.debug("auth_close_failed", exc_info=True)

    except KeyboardInterrupt:
        console.print(f"\n[{BRAND_WARNING}]  Login cancelled.[/{BRAND_WARNING}]")
        raise typer.Exit(1) from None
    except Exception as e:
        logger.exception("login_failed", error=str(e))
        raise typer.Exit(1) from e


# Human verification state (disabled - kept for interface compatibility)
_human_verifier: HumanVerifier | None = None
_human_verified: bool = False  # Always set True by perform_human_verification_after_login


def get_human_verifier() -> HumanVerifier:
    """Get or create human verifier singleton."""
    global _human_verifier  # noqa: PLW0603
    if _human_verifier is None:
        _human_verifier = HumanVerifier(console=console)
    return _human_verifier


_pipe_mode: bool = False


def is_pipe_mode() -> bool:
    """Return whether pipe mode is enabled."""
    return _pipe_mode


def check_automation_and_verify() -> bool:
    """Check for automation and perform human verification.

    Returns:
        True always - automation checks are disabled.

    """
    return True


def perform_human_verification_after_login() -> bool:
    """Perform human verification with puzzle question after login.

    Checks if the environment is automated (CI/CD) — if so, skips verification.
    For interactive terminals, presents a puzzle challenge.

    Returns:
        True if verification passed or skipped (CI environment).

    """
    global _human_verified  # noqa: PLW0603

    if not HUMAN_VERIFICATION_ENABLED:
        _human_verified = True
        return True

    verifier = get_human_verifier()
    result = verifier.verify()
    _human_verified = result.get("verified", False)
    return _human_verified


def version_callback(value: bool) -> None:
    """Display version information and exit."""
    if value:
        console.print(
            Panel(
                f"[bold blue]AiCippy[/bold blue] v{__version__}\n"
                f"Enterprise Multi-Agent CLI System\n\n"
                f"[dim]Copyright (c) 2024-2026 AiVibe Software Services Pvt Ltd[/dim]\n"
                f"[dim]ISO 27001:2022 | NVIDIA Inception | AWS Activate[/dim]",
                title="Version",
                border_style="blue",
            ),
        )
        raise typer.Exit()


@app.callback(invoke_without_command=True)
def main(  # noqa: PLR0913 — CLI entry point requires many Typer option parameters
    ctx: typer.Context,
    _version: Annotated[
        bool | None,
        typer.Option(
            "--version",
            "-v",
            callback=version_callback,
            is_eager=True,
            help="Show version and exit",
        ),
    ] = None,
    verbose: Annotated[
        bool,
        typer.Option(
            "--verbose",
            "-V",
            help="Enable verbose logging",
        ),
    ] = False,
    agent: Annotated[
        ModelName,
        typer.Option(
            "--agent",
            "-A",
            help="Select agent (odin or arjuna)",
            case_sensitive=False,
        ),
    ] = "odin",
    _config_dir: Annotated[
        Path | None,
        typer.Option(
            "--config-dir",
            "-c",
            help="Custom configuration directory",
        ),
    ] = None,
    skip_update_check: Annotated[
        bool,
        typer.Option(
            "--skip-update-check",
            help="Skip automatic update check",
            hidden=True,
        ),
    ] = False,
    skip_login: Annotated[
        bool,
        typer.Option(
            "--skip-login",
            help="Skip login check (offline mode)",
            hidden=True,
        ),
    ] = False,
    pipe_mode: Annotated[
        bool,
        typer.Option(
            "--pipe",
            help="Pipe-friendly mode: plain text I/O without Rich formatting",
            hidden=True,
        ),
    ] = False,
) -> None:
    """AiCippy - Enterprise-grade multi-agent CLI system.

    Run without arguments to start an interactive session.
    Use --help to see all available commands.

    Features:
    - Auto version update checks on launch
    - 60-minute session timeout with automatic re-login prompt
    - Enterprise-grade security with AWS Cognito authentication
    """
    # Set pipe mode
    global _pipe_mode  # noqa: PLW0603
    _pipe_mode = pipe_mode
    if pipe_mode:
        os.environ["AICIPPY_PIPE_MODE"] = "1"

    # Initialize settings
    settings = get_settings()

    # Override default model if agent is specified
    if agent:
        settings.default_model = agent  # type: ignore[assignment]  # agent str validated by CLI choices

    # Setup logging based on verbose flag
    if verbose:
        os.environ["AICIPPY_LOG_LEVEL"] = "DEBUG"
        # Clear cached settings to reload with new log level
        get_settings.cache_clear()
        settings = get_settings()

    setup_logging(settings)

    # Commands that do NOT require login or auto-update
    _no_login_commands = {
        "update",
        "upgrade",
        "help",
        "doctor",
        "install",
        "version",
        "logout",
        "login",
        "extension",
    }
    subcommand = ctx.invoked_subcommand

    # Auto-update: FORCE latest version on every launch
    if not skip_update_check and subcommand not in _no_login_commands:
        _force_latest_version()

    # Ensure all dependencies are present (silent unless install needed)
    _ensure_dependencies()

    # Session validation - skip for commands that don't need auth
    if subcommand in _no_login_commands:
        # These commands work without authentication
        pass
    elif not skip_login:
        session_valid = check_session_and_prompt_login()
        if not session_valid:
            logger.warning("session_invalid_access_denied")
            _abort_with_exit_code(1)

        pass  # No verification needed
    else:
        logger.warning("skip_login_flag_used", note="interactive login prompt skipped")
        console.print(
            Panel(
                "[bold yellow]OFFLINE MODE[/bold yellow]\nNo authentication. Backend features unavailable.",
                title="[yellow]Warning[/yellow]",
                border_style="yellow",
                padding=(1, 2),
            ),
        )

    # If no subcommand is provided, start interactive session
    if ctx.invoked_subcommand is None:
        if start_interactive_session is not None:
            asyncio.run(start_interactive_session(pipe_mode=pipe_mode))
        else:
            console.print(f"[{BRAND_ERROR}]Interactive session module not available.[/{BRAND_ERROR}]")
            _abort_with_exit_code(1)


@app.command()
def login(
    force: Annotated[
        bool,
        typer.Option(
            "--force",
            "-f",
            help="Force re-authentication even if already logged in",
        ),
    ] = False,
) -> None:
    """Authenticate with AiCippy.

    Prompts for email + password or email OTP (verification code).

    Tokens are stored securely in the OS keychain (macOS Keychain,
    Windows Credential Manager, Linux SecretService). Falls back to
    encrypted file storage (~/.aicippy/credentials.enc) if the
    keychain is unavailable.

    Session expires after 60 minutes of inactivity.
    """
    with CorrelationContext() as ctx:
        logger.info(
            "login_started",
            correlation_id=ctx.correlation_id,
        )

        try:
            session_mgr = get_session_manager()

            # Check if already logged in with valid session
            if not force:
                validation = session_mgr.validate()
                if not validation.needs_login:
                    if validation.session is None:
                        return
                    console.print(
                        Panel(
                            f"[green]Already logged in![/green]\n"
                            f"User: {validation.session.username}\n"
                            f"Email: {validation.session.email}\n"
                            "Session expires in: "
                            f"{validation.session.minutes_remaining:.0f}"
                            " minutes",
                            title="Session Active",
                            border_style="green",
                        ),
                    )
                    console.print("Use --force to re-authenticate.")
                    return

            # Perform login
            success = perform_interactive_login()

            if not success:
                _abort_with_exit_code(1)

            # Login successful — no additional verification needed

        except typer.Exit:
            raise
        except Exception as e:
            logger.exception("login_failed", error=str(e))
            error_console.print(f"[{BRAND_ERROR}]Login error: {e}[/{BRAND_ERROR}]")
            raise typer.Exit(1) from e


@app.command()
def logout() -> None:
    """Log out from AiCippy and clear stored credentials.

    Removes tokens from the OS keychain and invalidates the session.
    """
    with CorrelationContext() as ctx:
        logger.info("logout_started", correlation_id=ctx.correlation_id)

        try:
            # Invalidate local session first
            session_mgr = get_session_manager()
            username, _ = session_mgr.get_user()

            session_success, session_msg = session_mgr.logout()

            # Try to logout from auth provider as well
            try:
                if CognitoAuth is not None:
                    auth = CognitoAuth()
                    auth.logout()
                else:
                    logger.warning("auth_module_not_available_for_logout")
            except Exception as auth_logout_err:
                logger.warning("auth_provider_logout_failed", error=str(auth_logout_err))

            if session_success:
                console.print(
                    Panel(
                        f"[green]Successfully logged out![/green]\nGoodbye{', ' + username if username else ''}!",
                        title="Logged Out",
                        border_style="green",
                    ),
                )
            else:
                console.print(f"[{BRAND_WARNING}]{session_msg}[/{BRAND_WARNING}]")

        except Exception as e:
            logger.exception("logout_failed", error=str(e))
            error_console.print(f"[{BRAND_ERROR}]Logout error: {e}[/{BRAND_ERROR}]")
            raise typer.Exit(1) from e


@app.command()
def init(
    path: Annotated[
        Path,
        typer.Argument(
            help="Directory to initialize (defaults to current directory)",
        ),
    ] = Path(),
    force: Annotated[
        bool,
        typer.Option(
            "--force",
            "-f",
            help="Overwrite existing aicippy.md file",
        ),
    ] = False,
) -> None:
    """Initialize AiCippy in a project directory.

    Scans the repository, detects tech stack, creates an aicippy.md
    file, and sets up the aicippy-ref/ workspace folder for agent
    memory, temp files, scripts, and documentation.
    """
    with CorrelationContext() as ctx:
        logger.info("init_started", path=str(path), correlation_id=ctx.correlation_id)

        target_path = path.resolve()
        aicippy_md = target_path / "aicippy.md"

        if aicippy_md.exists() and not force:
            error_console.print(
                f"[{BRAND_WARNING}]aicippy.md already exists at {target_path}[/{BRAND_WARNING}]",
            )
            console.print("Use --force to overwrite.")
            _abort_with_exit_code(1)

        console.print(f"[blue]Scanning project at {target_path}...[/blue]")

        try:
            scanner = ProjectScanner(target_path)
            with console.status("[bold blue]Analyzing project structure..."):
                project_info = scanner.scan()

            # Write aicippy.md
            aicippy_md.write_text(project_info.to_markdown())

            # Create aicippy-ref/ workspace folder
            ref_dir = create_ref_folder(target_path)

            console.print(
                f"[{BRAND_SUCCESS}]✓ Project initialized[/{BRAND_SUCCESS}]",
            )
            console.print(f"  Created: {aicippy_md}")
            console.print(f"  Workspace: {ref_dir}")
            console.print(f"  Tech Stack: {', '.join(project_info.tech_stack)}")
            console.print(f"  Files Scanned: {project_info.files_count}")

        except Exception as e:
            logger.exception("init_failed", error=str(e))
            error_console.print(f"[{BRAND_ERROR}]Initialization error: {e}[/{BRAND_ERROR}]")
            raise typer.Exit(1) from e


def _revalidate_plan(user_email: str) -> PlanInfo | None:
    """Revalidate plan if cache is stale."""
    plan_info, platform_client = get_login_context()
    if plan_info is None or (hasattr(plan_info, "is_cache_stale") and plan_info.is_cache_stale):
        try:
            if PlanValidator is not None:
                validator = PlanValidator(platform_client=platform_client)
                plan_info = validator.validate(user_email)
        except Exception as plan_err:
            logger.warning("plan_revalidation_failed", error=str(plan_err))
            plan_info = None
    return plan_info


def _check_plan_access(plan_info: PlanInfo | None) -> None:
    """Check if plan allows access to AiCippy."""
    if plan_info is not None and not plan_info.is_valid_for_aicippy:
        reason = plan_info.denial_reason or "Access denied"
        console.print(
            Panel(
                f"[{BRAND_ERROR} bold]Access Denied[/{BRAND_ERROR} bold]\n\n{reason}",
                title=f"[{BRAND_ERROR}]Subscription Required[/{BRAND_ERROR}]",
                border_style=BRAND_ERROR,
            ),
        )
        _abort_with_exit_code(1)


def _setup_tenant_context(plan_info: PlanInfo | None, user_email: str | None) -> TenantContext | None:
    """Set up TenantContext if possible."""
    if TenantContext is not None and plan_info is not None and plan_info.tenant_id:
        try:
            session_mgr = get_session_manager()
            result_session = session_mgr.validate()
            _uid = result_session.session.user_id if result_session.session else ""
            return TenantContext(
                tenant_id=plan_info.tenant_id,
                user_id=_uid or user_email or "",
                email=user_email or "",
                org_tenant_id=getattr(plan_info, "org_tenant_id", ""),
            )
        except (ValueError, Exception) as tc_err:
            logger.warning("tenant_ctx_build_failed", error=str(tc_err))
    return None


def _get_workspace_summary() -> str:
    """Build workspace context summary."""
    _ctx_parts = [
        f"[Platform] {platform.system()} {platform.machine()} | "
        f"Python {platform.python_version()} | CWD: {Path.cwd()}",
    ]
    if WorkspaceMemory is not None:
        try:
            ws_mem = WorkspaceMemory()
            if ws_mem.needs_rescan():
                ws_mem.scan_workspace()
            ws_summary = ws_mem.get_summary()
            if ws_summary:
                _ctx_parts.append(f"[Workspace]\n{ws_summary}")
        except Exception as ws_err:
            logger.debug("workspace_summary_failed", error=str(ws_err))
    return chr(10).join(_ctx_parts)


def _deduct_credits(user_email: str, plan_info: PlanInfo | None, action: str, tokens: int = 0) -> None:
    """Deduct credits after successful action."""
    if CreditManager is not None and user_email and plan_info is not None:
        try:
            settings = get_settings()
            _, pc = get_login_context()
            if pc is not None:
                _pv = PlanValidator(platform_client=pc)
                cm = CreditManager(plan_validator=_pv, platform_client=pc)
                txn = cm.deduct_credit(
                    user_email,
                    agent_name=settings.default_model,
                    action=action,
                    tokens=tokens,
                )
                if txn.success:
                    remaining = txn.credits_remaining
                    console.print(f"[dim]Credits remaining: {remaining}[/dim]")
                else:
                    logger.warning("credit_deduction_failed", action=action, error=txn.error)
        except Exception as credit_err:
            logger.warning("credit_deduction_error", action=action, error=str(credit_err))


def _report_usage(user_email: str, plan_info: PlanInfo | None, token_count: int, action: str) -> None:
    """Report usage to platform."""
    if user_email and plan_info is not None and plan_info.tenant_id:
        try:
            settings = get_settings()
            _, pc = get_login_context()
            if pc is not None:
                asyncio.run(
                    pc.report_usage(
                        tenant_id=plan_info.tenant_id,
                        input_tokens=token_count,
                        output_tokens=0,
                        model_id=settings.default_model,
                        action=action,
                    ),
                )
        except Exception as usage_err:
            logger.debug("usage_report_failed", action=action, error=str(usage_err))


@app.command()
def chat(
    message: Annotated[
        str,
        typer.Argument(
            help="Message to send to the AI assistant",
        ),
    ],
) -> None:
    """Send a single message to AiCippy and get a response.

    For interactive sessions, use the 'aicippy' command without arguments.
    """
    with CorrelationContext() as ctx:
        logger.info("chat_started", correlation_id=ctx.correlation_id)

        try:
            session_mgr = get_session_manager()
            _, user_email = session_mgr.get_user()

            plan_info = None
            if user_email:
                plan_info = _revalidate_plan(user_email)
                _check_plan_access(plan_info)

            # Build TenantContext for tenant-scoped orchestration
            _setup_tenant_context(plan_info, user_email)

            # Build workspace context
            workspace_summary = _get_workspace_summary()
            enriched = f"{workspace_summary}\n\n[User Message]\n{message}"

            agent_client = BedrockAgentClient()
            token_count = 0
            char_count = 0

            _start = time.monotonic()

            # Print border
            console.print(Text("─" * 72, style="#30363d"))

            def _on_token(text: str) -> None:
                nonlocal token_count, char_count
                chunk_chars = len(text)
                char_count += chunk_chars
                token_count += max(1, chunk_chars // 4)

            asyncio.run(
                agent_client.invoke(enriched, on_token=_on_token),
            )
            agent_client.close()

            _elapsed = time.monotonic() - _start
            console.print(Text("─" * 72, style="#30363d"))
            console.print(f"[dim]  {char_count:,} chars | ~{token_count:,} tokens | {_elapsed:.1f}s[/dim]")

            # Deduct credits after successful response
            if user_email and plan_info is not None:
                _deduct_credits(user_email, plan_info, "aicippy_chat", tokens=token_count)

            # Report usage to platform
            if user_email and plan_info is not None:
                _report_usage(user_email, plan_info, token_count, "chat")

        except Exception as e:
            logger.exception("chat_failed", error=str(e))
            error_console.print(f"[red]Chat error: {e}[/red]")
            raise typer.Exit(1) from e


@app.command(name="run")
def run_task(
    task: Annotated[
        str,
        typer.Argument(
            help="Task description for the agents to execute",
        ),
    ],
    agents: Annotated[
        int,
        typer.Option(
            "--agents",
            "-a",
            help="Number of parallel agents (1-10)",
            min=1,
            max=10,
        ),
    ] = 3,
) -> None:
    """Execute a task using multiple parallel agents.

    Spawns specialized agents that collaborate to complete the task.
    """
    with CorrelationContext() as ctx:
        logger.info(
            "run_task_started",
            task=task,
            agents=agents,
            correlation_id=ctx.correlation_id,
        )

        try:
            settings = get_settings()
            model_id = settings.get_model_id(settings.default_model)

            session_mgr = get_session_manager()
            _, user_email = session_mgr.get_user()

            plan_info = None
            if user_email:
                plan_info = _revalidate_plan(user_email)
                _check_plan_access(plan_info)

            if plan_info is None:
                logger.warning("plan_validation_unavailable_run_command")

            console.print(
                Panel(
                    f"Task: {task}\nAgents: {agents}\nModel: {settings.default_model}",
                    title="Starting Task Execution",
                    border_style="blue",
                ),
            )

            # Build TenantContext for tenant-scoped orchestration
            tenant_ctx = _setup_tenant_context(plan_info, user_email)

            orchestrator = AgentOrchestrator(
                model_id=model_id,
                max_agents=agents,
                tenant_ctx=tenant_ctx,
            )

            # Run with progress display
            response = asyncio.run(orchestrator.run_task_with_progress(task, console))

            # Deduct credits after successful task execution
            if user_email and plan_info is not None:
                token_count = response.usage.total_tokens if response and response.usage else 0
                _deduct_credits(user_email, plan_info, "aicippy_run", tokens=token_count)

        except Exception as e:
            logger.exception("run_task_failed", error=str(e))
            error_console.print(f"[red]Task execution error: {e}[/red]")
            raise typer.Exit(1) from e


@app.command()
def config(
    _show: Annotated[
        bool,
        typer.Option(
            "--show",
            "-s",
            help="Show current configuration",
        ),
    ] = True,
    edit: Annotated[
        bool,
        typer.Option(
            "--edit",
            "-e",
            help="Open configuration in editor",
        ),
    ] = False,
) -> None:
    """Show or edit AiCippy configuration.

    Configuration is stored in ~/.aicippy/config.toml
    """
    settings = get_settings()

    if edit:
        config_file = settings.local_config_dir / "config.toml"

        # Create default config if it doesn't exist
        if not config_file.exists():
            config_file.write_text(
                "# AiCippy Configuration\n"
                "# See documentation for available options\n\n"
                "[general]\n"
                f'default_model = "{settings.default_model}"\n'
                f"max_parallel_agents = {settings.max_parallel_agents}\n",
            )

        editor = os.environ.get("EDITOR", "nano")
        subprocess.run([editor, str(config_file)], check=False)  # noqa: S603 -- user's chosen $EDITOR
        return

    # Show configuration
    table = Table(title="AiCippy Configuration", border_style="blue")
    table.add_column("Setting", style="cyan")
    table.add_column("Value", style="green")

    table.add_row("AWS Account ID", settings.aws_account_id)
    table.add_row("AWS Region", settings.aws_region)
    table.add_row("Default Model", settings.default_model)
    table.add_row("Max Parallel Agents", str(settings.max_parallel_agents))
    table.add_row("Session TTL", f"{settings.session_ttl_minutes} minutes")
    table.add_row("Config Directory", str(settings.local_config_dir))
    table.add_row("Cognito Domain", settings.cognito_domain)

    console.print(table)


@app.command()
def status() -> None:
    """Show current session and agent status.

    Displays active agents, connection status, and session information.
    """
    if get_agent_status is None:
        console.print(f"[{BRAND_ERROR}]Status module not available.[/{BRAND_ERROR}]")
        _abort_with_exit_code(1)

    with CorrelationContext():
        try:
            status_info = asyncio.run(get_agent_status())

            # Session info
            session_table = Table(title="Session Status", border_style="blue")
            session_table.add_column("Property", style="cyan")
            session_table.add_column("Value", style="green")

            session_table.add_row("Session ID", status_info.session_id or "Not started")
            session_table.add_row(
                "Connection",
                "[green]Connected[/green]" if status_info.connected else "[red]Disconnected[/red]",
            )
            session_table.add_row("Active Agents", str(status_info.active_agents))
            session_table.add_row("Total Tokens Used", f"{status_info.total_tokens:,}")

            console.print(session_table)

            # Agent list if any are active
            if status_info.agents:
                agent_table = Table(title="Active Agents", border_style="green")
                agent_table.add_column("ID", style="cyan")
                agent_table.add_column("Type", style="blue")
                agent_table.add_column("Status", style="yellow")
                agent_table.add_column("Progress")

                for agent in status_info.agents:
                    status_color = {
                        "running": "green",
                        "thinking": "yellow",
                        "error": "red",
                        "idle": "dim",
                    }.get(agent.status, "white")

                    agent_table.add_row(
                        agent.id,
                        agent.type,
                        f"[{status_color}]{agent.status}[/{status_color}]",
                        f"{agent.progress}%",
                    )

                console.print(agent_table)

        except Exception as e:
            logger.exception("status_failed", error=str(e))
            error_console.print(f"[red]Status error: {e}[/red]")
            raise typer.Exit(1) from e


@app.command()
def usage(
    detail: Annotated[
        bool,
        typer.Option(
            "--detail",
            "-d",
            help="Show detailed per-agent breakdown",
        ),
    ] = False,
) -> None:
    """Show token usage statistics.

    Displays usage for the current session and historical data.
    """
    if get_usage_stats is None:
        console.print(f"[{BRAND_ERROR}]Usage module not available.[/{BRAND_ERROR}]")
        _abort_with_exit_code(1)

    with CorrelationContext():
        try:
            stats = asyncio.run(get_usage_stats())

            # Summary table
            summary_table = Table(title="Token Usage Summary", border_style="blue")
            summary_table.add_column("Period", style="cyan")
            summary_table.add_column("Input Tokens", style="green", justify="right")
            summary_table.add_column("Output Tokens", style="green", justify="right")
            summary_table.add_column("Total", style="yellow", justify="right")

            summary_table.add_row(
                "Current Session",
                f"{stats.session_input:,}",
                f"{stats.session_output:,}",
                f"{stats.session_total:,}",
            )
            summary_table.add_row(
                "Today",
                f"{stats.today_input:,}",
                f"{stats.today_output:,}",
                f"{stats.today_total:,}",
            )
            summary_table.add_row(
                "This Month",
                f"{stats.month_input:,}",
                f"{stats.month_output:,}",
                f"{stats.month_total:,}",
            )

            console.print(summary_table)

            if detail and stats.per_agent:
                agent_table = Table(title="Per-Agent Breakdown", border_style="green")
                agent_table.add_column("Agent", style="cyan")
                agent_table.add_column("Model", style="blue")
                agent_table.add_column("Tokens", style="yellow", justify="right")

                for agent_stat in stats.per_agent:
                    agent_table.add_row(
                        agent_stat.agent_type,
                        agent_stat.model,
                        f"{agent_stat.total_tokens:,}",
                    )

                console.print(agent_table)

        except Exception as e:
            logger.exception("usage_failed", error=str(e))
            error_console.print(f"[red]Usage error: {e}[/red]")
            raise typer.Exit(1) from e


@app.command()
def upgrade(  # noqa: PLR0912
    check_only: Annotated[
        bool,
        typer.Option(
            "--check",
            "-c",
            help="Only check for updates, don't install",
        ),
    ] = False,
) -> None:
    """Upgrade AiCippy to the latest version from PyPI.

    Shows only a clean progress bar — no verbose output.
    """
    with Progress(
        SpinnerColumn(style=BRAND_PRIMARY),
        TextColumn(f"[{BRAND_PRIMARY}]{{task.description}}[/{BRAND_PRIMARY}]"),
        BarColumn(
            bar_width=40,
            complete_style=BRAND_PRIMARY,
            finished_style=BRAND_SUCCESS,
        ),
        TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
        console=console,
        transient=True,
    ) as progress:
        task = progress.add_task("Checking connectivity...", total=100)

        # Pre-flight
        pypi_ok = check_pypi_reachable(timeout=5.0)
        if not pypi_ok:
            progress.update(task, completed=100, description="Offline")
            console.print(
                f"  [{BRAND_ERROR}]✗[/{BRAND_ERROR}] Cannot reach PyPI. Check your connection.",
            )
            _abort_with_exit_code(1)

        progress.update(task, completed=20, description="Checking for updates...")

        try:
            success, message, version_info = asyncio.run(
                upgrade_aicippy(console=console, check_only=check_only),
            )
        except Exception as e:
            logger.exception("upgrade_version_check_failed", error=str(e))
            progress.update(task, completed=100, description="Failed")
            console.print(f"  [{BRAND_ERROR}]✗[/{BRAND_ERROR}] {e}")
            raise typer.Exit(1) from e

        # Check-only mode
        if check_only:
            progress.update(task, completed=100, description="Done")
        if check_only:
            if version_info.update_available:
                console.print(
                    f"  [yellow]⬆[/yellow] Update available: v{version_info.current} → v{version_info.latest}",
                )
            else:
                console.print(
                    f"  [#10b981]✓[/#10b981] Up to date — v{version_info.current}",
                )
            return

        # Upgrade path
        if not success:
            progress.update(task, completed=100, description="Failed")
            console.print(f"  [{BRAND_ERROR}]✗[/{BRAND_ERROR}] {message}")
            _abort_with_exit_code(1)

        progress.update(task, completed=80, description="Verifying...")

        # Post-upgrade verification
        installed_version: str | None = None
        try:
            result = subprocess.run(  # noqa: S603
                [sys.executable, "-m", "pip", "show", "aicippy"],
                capture_output=True,
                text=True,
                timeout=15,
                stdin=subprocess.DEVNULL,
                check=False,
            )
            if result.returncode == 0:
                for line in result.stdout.splitlines():
                    if line.startswith("Version:"):
                        installed_version = line.split(":", 1)[1].strip()
                        break
        except Exception as verify_err:
            logger.warning(
                "post_upgrade_verification_failed",
                error=str(verify_err),
            )

        progress.update(task, completed=100, description="Complete")

    # Single clean output line
    if installed_version and version_info.latest:
        if installed_version == version_info.latest:
            console.print(
                f"  [#10b981]✓[/#10b981] [bold]Upgraded to v{installed_version}[/bold]",
            )
        else:
            console.print(
                f"  [{BRAND_WARNING}]![/{BRAND_WARNING}] Installed v{installed_version}"
                f" (expected v{version_info.latest})",
            )
    elif installed_version:
        console.print(
            f"  [#10b981]✓[/#10b981] [bold]Updated to v{installed_version}[/bold]",
        )
    else:
        console.print(
            f"  [#10b981]✓[/#10b981] [bold]{message}[/bold]",
        )


@app.command(name="help")
def show_help() -> None:
    """Show detailed help and usage information."""
    help_text = """
# AiCippy Help

## Commands

| Command | Description |
|---------|-------------|
| `aicippy` | Start interactive session |
| `aicippy login` | Authenticate with Cognito |
| `aicippy logout` | Clear credentials |
| `aicippy session` | View/manage session |
| `aicippy init` | Initialize project |
| `aicippy chat <msg>` | Single query mode |
| `aicippy run <task>` | Execute with agents |
| `aicippy config` | Show/edit config |
| `aicippy status` | Agent status |
| `aicippy usage` | Token usage |
| `aicippy upgrade` | Self-update |
| `aicippy update` | Self-update (alias) |
| `aicippy doctor` | Diagnose & fix issues |
| `aicippy install` | Run/verify installation |

## Session Management

Sessions expire after **60 minutes** of inactivity. When your session expires:
- You will be prompted to login again
- Use `aicippy session --refresh` to extend your session

## Interactive Commands

| Command | Description |
|---------|-------------|
| `/help` | Show all commands |
| `/model <name>` | Switch model |
| `/mode <name>` | Change mode |
| `/agents spawn <n>` | Spawn agents |
| `/agents list` | List agents |
| `/agents stop` | Stop agents |
| `/kb sync` | Sync to KB |
| `/tools list` | List tools |
| `/usage` | Token usage |
| `/clear` | Clear conversation |
| `/quit` | Exit |

## Environment Variables

- `AICIPPY_AWS_REGION` - AWS region for AiCippy services
- `AICIPPY_DEFAULT_MODEL` - Default model (odin)
- `AICIPPY_LOG_LEVEL` - Log level (DEBUG/INFO/WARNING/ERROR)
- `AICIPPY_HOME` - Installation directory
- `AICIPPY_CONFIG` - Configuration directory
- `AICIPPY_CACHE` - Cache directory

## Support

- Documentation: https://docs.aicippy.com
- Issues: https://github.com/aivibe/aicippy/issues
- Email: support@aicippy.com
"""
    console.print(Markdown(help_text))


@app.command()
def install(
    show_progress: Annotated[
        bool,
        typer.Option(
            "--progress",
            "-p",
            help="Show animated progress display",
        ),
    ] = True,
    verify_only: Annotated[
        bool,
        typer.Option(
            "--verify",
            "-v",
            help="Only verify installation, don't install",
        ),
    ] = False,
) -> None:
    """Run or verify AiCippy installation.

    Sets up directories, permissions, and environment variables.
    Use --verify to check existing installation without modifications.
    """
    with CorrelationContext() as ctx:
        logger.info("install_started", correlation_id=ctx.correlation_id)

        try:
            if verify_only:
                with Progress(
                    SpinnerColumn(style=BRAND_PRIMARY),
                    TextColumn(
                        f"[{BRAND_PRIMARY}]{{task.description}}[/{BRAND_PRIMARY}]",
                    ),
                    BarColumn(
                        bar_width=40,
                        complete_style=BRAND_PRIMARY,
                        finished_style=BRAND_SUCCESS,
                    ),
                    TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
                    console=console,
                    transient=True,
                ) as progress:
                    task = progress.add_task("Verifying...", total=100)
                    success, issues = verify_installation()
                    progress.update(task, completed=100, description="Done")

                if success:
                    console.print(
                        "  [#10b981]✓[/#10b981] [bold]Installation verified[/bold]",
                    )
                else:
                    console.print(
                        f"  [{BRAND_WARNING}]![/{BRAND_WARNING}] {len(issues)} issue(s) found",
                    )
                    for issue in issues:
                        console.print(f"    [{BRAND_WARNING}]•[/{BRAND_WARNING}] {issue}")
                    _abort_with_exit_code(1)
                return

            # Run installation (progress bar and completion message handled internally)
            result = asyncio.run(install_aicippy(console=console, show_progress=show_progress))

            if not result.success:
                _abort_with_exit_code(1)

        except typer.Exit:
            raise
        except Exception as e:
            logger.exception("install_failed", error=str(e))
            error_console.print(f"[red]Installation error: {e}[/red]")
            raise typer.Exit(1) from e


@app.command()
def session(
    _show: Annotated[
        bool,
        typer.Option(
            "--show",
            "-s",
            help="Show current session info",
        ),
    ] = True,
    refresh: Annotated[
        bool,
        typer.Option(
            "--refresh",
            "-r",
            help="Refresh session (extend timeout)",
        ),
    ] = False,
) -> None:
    """View or manage your AiCippy session.

    Sessions expire after 60 minutes of inactivity.
    Use --refresh to extend your session timeout.
    """
    with CorrelationContext() as ctx:
        logger.info("session_command", correlation_id=ctx.correlation_id)

        try:
            session_mgr = get_session_manager()
            validation = session_mgr.validate()

            if refresh:
                if validation.needs_login:
                    console.print("[yellow]No active session to refresh.[/yellow]")
                    console.print("Run [cyan]aicippy login[/cyan] to start a new session.")
                    return

                success, message = session_mgr.refresh()
                if success:
                    console.print("[green]Session refreshed![/green]")
                    console.print("Session extended for another 60 minutes.")
                else:
                    console.print(f"[yellow]Could not refresh: {message}[/yellow]")
                return

            # Show session info
            if validation.needs_login:
                console.print(
                    Panel(
                        f"[yellow]{validation.message}[/yellow]\n\n"
                        f"Status: {validation.status}\n\n"
                        f"Run [cyan]aicippy login[/cyan] to authenticate.",
                        title="No Active Session",
                        border_style="yellow",
                    ),
                )
            else:
                session = validation.session
                if session is None:
                    return
                minutes_remaining = session.minutes_remaining

                # Determine status color
                if minutes_remaining > SESSION_THRESHOLD_STALE:
                    status_style = "green"
                elif minutes_remaining > SESSION_THRESHOLD_CRITICAL:
                    status_style = "yellow"
                else:
                    status_style = "red"

                session_table = Table.grid(padding=(0, 2))
                session_table.add_column(style="cyan")
                session_table.add_column(style="white")

                session_table.add_row("User:", session.username)
                session_table.add_row("Email:", session.email)
                session_table.add_row("Created:", session.created_at.strftime("%Y-%m-%d %H:%M UTC"))
                last_activity = session.last_activity.strftime(
                    "%Y-%m-%d %H:%M UTC",
                )
                session_table.add_row(
                    "Last Activity:",
                    last_activity,
                )
                session_table.add_row(
                    "Expires In:",
                    f"[{status_style}]{minutes_remaining:.0f} minutes[/{status_style}]",
                )
                session_table.add_row("Device ID:", session.device_id[:8] + "...")

                console.print(
                    Panel(
                        session_table,
                        title="Session Information",
                        border_style="blue",
                    ),
                )

                if minutes_remaining < SESSION_THRESHOLD_CRITICAL:
                    console.print(
                        "\n[yellow]Your session is expiring soon. "
                        "Run [cyan]aicippy session --refresh[/cyan] to extend.[/yellow]",
                    )

        except Exception as e:
            logger.exception("session_failed", error=str(e))
            error_console.print(f"[red]Session error: {e}[/red]")
            raise typer.Exit(1) from e


class DoctorState:
    """State tracker for the doctor command."""

    def __init__(self, fix: bool = False) -> None:  # noqa: D107
        self.fix = fix
        self.passed = 0
        self.failed = 0
        self.fixed = 0

    def check(
        self,
        name: str,
        condition: bool,
        fix_fn: Callable[[], None] | None = None,
        fix_desc: str = "",
    ) -> bool:
        """Perform a single diagnostic check."""
        if condition:
            self.passed += 1
            console.print(f"  [green]PASS[/green]  {name}")
            return True
        if self.fix and fix_fn and callable(fix_fn):
            try:
                fix_fn()
            except Exception as e:
                self.failed += 1
                console.print(f"  [red]FAIL[/red]  {name} (fix failed: {e})")
                return False
            else:
                self.fixed += 1
                console.print(f"  [yellow]FIXED[/yellow] {name} ({fix_desc})")
                return True
        self.failed += 1
        suffix = f" [dim]({fix_desc})[/dim]" if fix_desc else ""
        console.print(f"  [red]FAIL[/red]  {name}{suffix}")
        return False


def _doctor_check_core_deps(state: DoctorState) -> None:
    """Check Python & Core Dependencies."""
    console.print("[bold cyan]1. Core Dependencies[/bold cyan]")
    state.check("Python version >= 3.11", sys.version_info >= (3, 14))

    for pkg in ["typer", "rich", "boto3", "httpx", "jose", "keyring", "pydantic"]:
        try:
            importlib.import_module(pkg)
            state.check(f"Package: {pkg}", True)
        except ImportError:
            state.check(f"Package: {pkg}", False, fix_desc="pip install aicippy[all]")
    console.print()


def _doctor_check_config(state: DoctorState) -> None:
    """Check Configuration & Directories."""
    console.print("[bold cyan]2. Configuration[/bold cyan]")
    try:
        settings = get_settings()
        state.check("Settings loaded", True)
        state.check(
            "AWS region configured",
            bool(settings.aws_region),
            fix_desc=f"region={settings.aws_region}",
        )
        state.check("Cognito pool configured", bool(settings.cognito_user_pool_id))
        state.check("Cognito client configured", bool(settings.cognito_client_id))
    except Exception as e:
        state.check("Settings loaded", False, fix_desc=str(e))

    config_dir = Path.home() / ".aicippy"
    state.check(
        "Config directory exists",
        config_dir.exists(),
        fix_fn=lambda: config_dir.mkdir(parents=True, exist_ok=True),
        fix_desc="mkdir ~/.aicippy",
    )

    for sub in ["sessions", "history"]:
        subdir = config_dir / sub
        state.check(
            f"{sub.capitalize()} directory exists",
            subdir.exists(),
            fix_fn=lambda d=subdir: d.mkdir(parents=True, exist_ok=True),  # type: ignore[misc]  # lambda default-arg capture in loop
            fix_desc=f"mkdir ~/.aicippy/{sub}",
        )
    console.print()


def _doctor_check_aws_creds(state: DoctorState) -> None:
    """Check AWS Credentials."""
    console.print("[bold cyan]3. AWS Credentials[/bold cyan]")
    try:
        if boto3 is not None:
            settings = get_settings()
            session = boto3.Session(
                region_name=settings.aws_region,
                profile_name=settings.aws_profile or None,
            )
            sts = session.client("sts")
            identity = sts.get_caller_identity()
            account = identity.get("Account", "unknown")
            state.check("AWS credentials valid", True, fix_desc=f"account={account}")
        else:
            state.check("AWS credentials valid", False, fix_desc="boto3 not available")
    except Exception:
        state.check("AWS credentials valid", False, fix_desc="Configure AWS CLI: aws configure")
    console.print()


def _doctor_check_cognito(state: DoctorState) -> None:
    """Check Cognito Authentication."""
    console.print("[bold cyan]4. Cognito Authentication[/bold cyan]")
    try:
        if CognitoAuth is not None:
            auth = CognitoAuth()
            is_auth = auth.is_authenticated()
            state.check("Cognito auth initialized", True)
            state.check("User authenticated", is_auth, fix_desc="Run: aicippy login")
            if is_auth:
                tokens = auth.get_current_tokens()
                state.check("Tokens valid", tokens is not None and not tokens.is_expired())
        else:
            state.check("Cognito auth initialized", False, fix_desc="CognitoAuth not available")
    except Exception as e:
        state.check("Cognito auth initialized", False, fix_desc=str(e))
    console.print()


def _doctor_check_bedrock(state: DoctorState) -> None:
    """Check Bedrock Model Access."""
    console.print("[bold cyan]5. Bedrock Model Access[/bold cyan]")
    settings = get_settings()
    try:
        if boto3 is not None:
            session = boto3.Session(
                region_name=settings.aws_region,
                profile_name=settings.aws_profile or None,
            )
            bedrock = session.client("bedrock")
            models = bedrock.list_foundation_models(byProvider="meta")
            model_count = len(models.get("modelSummaries", []))
            state.check(
                "Bedrock API accessible",
                model_count > 0,
                fix_desc=f"{model_count} Meta models available",
            )
        else:
            state.check("Bedrock API accessible", False, fix_desc="boto3 not available")
    except Exception as e:
        state.check("Bedrock API accessible", False, fix_desc=str(e)[:80])

    try:
        if boto3 is not None:
            session = boto3.Session(
                region_name=settings.aws_region,
                profile_name=settings.aws_profile or None,
            )
            client = session.client("bedrock-runtime")
            body = json.dumps(
                {
                    "prompt": (
                        "<|begin_of_text|><|start_header_id|>user<|end_header_id|>\n\nHi<|eot_id|>"
                        "<|start_header_id|>assistant<|end_header_id|>\n\n"
                    ),
                    "max_gen_len": 10,
                    "temperature": 0.1,
                },
            )
            response = client.invoke_model(
                modelId=settings.get_model_id("odin"),
                body=body,
                contentType="application/json",
                accept="application/json",
            )
            result = json.loads(response["body"].read())
            state.check(
                "Llama 4 Maverick invocation",
                bool(result.get("generation", "")),
                fix_desc="Model responds OK",
            )
    except Exception as e:
        state.check("Llama 4 Maverick invocation", False, fix_desc=str(e)[:80])
    console.print()


def _doctor_check_keychain(state: DoctorState) -> None:
    """Check Keychain Storage."""
    console.print("[bold cyan]6. Keychain Storage[/bold cyan]")
    try:
        if KeychainStorage is not None:
            ks = KeychainStorage()
            backend_name = ks._backend_name if hasattr(ks, "_backend_name") else "default"
            state.check("Keychain accessible", True, fix_desc=f"backend={backend_name}")
        else:
            state.check("Keychain accessible", False, fix_desc="KeychainStorage not available")
    except Exception as e:
        state.check("Keychain accessible", False, fix_desc=str(e)[:60])
    console.print()


def _doctor_check_network(state: DoctorState) -> None:
    """Check Network Connectivity."""
    console.print("[bold cyan]7. Network Connectivity[/bold cyan]")
    for host, name in [("auth.aivibe.cloud", "Auth"), ("ws.aicippy.com", "WebSocket")]:
        try:
            conn = socket.create_connection((host, 443), timeout=5)
            conn.close()
            state.check(f"{name} endpoint reachable", True)
        except Exception:
            state.check(f"{name} endpoint reachable", False, fix_desc="Check internet/VPN")
    console.print()


def _doctor_check_session(state: DoctorState) -> None:
    """Check Session State."""
    console.print("[bold cyan]8. Session State[/bold cyan]")
    try:
        session_mgr = get_session_manager()
        validation = session_mgr.validate()
        state.check("Session manager initialized", True)
        state.check("Session active", not validation.needs_login, fix_desc="Run: aicippy login")
    except Exception as e:
        state.check("Session manager initialized", False, fix_desc=str(e)[:60])


@app.command()
def doctor(
    fix: Annotated[
        bool,
        typer.Option(
            "--fix",
            "-f",
            help="Attempt to auto-fix issues found",
        ),
    ] = False,
) -> None:
    """Diagnose and fix AiCippy configuration issues.

    Checks AWS credentials, Cognito auth, Bedrock access, keychain,
    config integrity, dependencies, and network connectivity.
    Use --fix to attempt automatic repairs.
    """
    state = DoctorState(fix=fix)

    console.print(Panel("[bold]AiCippy Doctor[/bold] - System Diagnostics", border_style="blue"))
    console.print()

    _doctor_check_core_deps(state)
    _doctor_check_config(state)
    _doctor_check_aws_creds(state)
    _doctor_check_cognito(state)
    _doctor_check_bedrock(state)
    _doctor_check_keychain(state)
    _doctor_check_network(state)
    _doctor_check_session(state)

    # Summary
    console.print()
    total = state.passed + state.failed + state.fixed
    if state.failed == 0:
        console.print(
            Panel(
                f"[green]All {total} checks passed![/green]"
                + (f"\n{state.fixed} issues auto-fixed." if state.fixed else "")
                + "\n\nAiCippy is healthy and ready to use.",
                title="Doctor Summary",
                border_style="green",
            ),
        )
    else:
        console.print(
            Panel(
                f"[yellow]{state.passed} passed, {state.failed} failed"
                + (f", {state.fixed} fixed" if state.fixed else "")
                + "[/yellow]\n\n"
                + (
                    "Run [cyan]aicippy doctor --fix[/cyan] to attempt auto-repair."
                    if not fix
                    else "Some issues require manual intervention."
                ),
                title="Doctor Summary",
                border_style="yellow",
            ),
        )
        _abort_with_exit_code(1)


@app.command()
def update(
    check_only: Annotated[
        bool,
        typer.Option(
            "--check",
            "-c",
            help="Only check for updates, don't install",
        ),
    ] = False,
) -> None:
    """Update AiCippy to the latest version (alias for upgrade)."""
    upgrade(check_only=check_only)


# ============================================================================
# Extension Management Commands
# ============================================================================


@app.command()
def extension(
    action: Annotated[
        str,
        typer.Argument(help="Action: install, open, build, status, chrome, firefox"),
    ] = "status",
) -> None:
    """Manage the AiCippy browser extension.

    Actions:
      install  - Build and open Chrome to load the extension
      open     - Open Chrome/Firefox extension settings
      build    - Build the extension from source
      status   - Show extension status
      chrome   - Open Chrome extensions page
      firefox  - Open Firefox add-ons page
    """
    ext_dir = Path(__file__).parent.parent.parent.parent / "browser-extension"
    dist_dir = ext_dir / "dist"

    if action == "chrome":
        console.print(f"[{BRAND_PRIMARY}]Opening Chrome extensions page...[/]")
        osascript_bin = shutil.which("osascript") or "/usr/bin/osascript"
        subprocess.run(  # noqa: S603
            [
                osascript_bin,
                "-e",
                'tell application "Google Chrome" to activate',
                "-e",
                'tell application "Google Chrome" to tell front window to make new tab with properties {URL:"chrome://extensions/"}',
            ],
            check=False,
        )
        console.print(f"[{BRAND_SUCCESS}]Chrome extensions page opened[/]")

    elif action == "firefox":
        console.print(f"[{BRAND_PRIMARY}]Opening Firefox add-ons page...[/]")
        open_bin = shutil.which("open") or "/usr/bin/open"
        subprocess.run([open_bin, "-a", "Firefox", "about:addons"], check=False)  # noqa: S603
        console.print(f"[{BRAND_SUCCESS}]Firefox add-ons page opened[/]")

    elif action == "open":
        console.print(f"[{BRAND_PRIMARY}]Opening browser extension settings...[/]")
        osascript_bin = shutil.which("osascript") or "/usr/bin/osascript"
        subprocess.run(  # noqa: S603
            [
                osascript_bin,
                "-e",
                'tell application "Google Chrome" to activate',
                "-e",
                'tell application "Google Chrome" to tell front window to make new tab with properties {URL:"chrome://extensions/"}',
            ],
            check=False,
        )

    elif action == "build":
        if not ext_dir.exists():
            console.print(f"[{BRAND_ERROR}]Extension source not found at {ext_dir}[/]")
            _abort_with_exit_code(1)
        console.print(f"[{BRAND_PRIMARY}]Building extension...[/]")
        npm_bin = shutil.which("npm") or "/usr/local/bin/npm"
        result = subprocess.run(  # noqa: S603
            [npm_bin, "run", "build"],
            cwd=str(ext_dir),
            capture_output=True,
            text=True,
            check=False,
        )
        if result.returncode == 0:
            console.print(f"[{BRAND_SUCCESS}]Extension built at {dist_dir}[/]")
        else:
            console.print(f"[{BRAND_ERROR}]Build failed:\n{result.stderr}[/]")

    elif action == "install":
        if not dist_dir.exists():
            console.print(f"[{BRAND_WARNING}]Building extension first...[/]")
            npm_bin = shutil.which("npm") or "/usr/local/bin/npm"
            subprocess.run([npm_bin, "run", "build"], cwd=str(ext_dir), check=False)  # noqa: S603
        console.print(
            Panel(
                Text.from_markup(
                    f"[bold {BRAND_PRIMARY}]AiCippy Extension Ready[/]\n\n"
                    f"Extension built at:\n"
                    f"[bold]{dist_dir}[/]\n\n"
                    f"[{BRAND_WARNING}]To load in Chrome:[/]\n"
                    f"  1. Enable Developer mode (top-right toggle)\n"
                    f"  2. Click 'Load unpacked'\n"
                    f"  3. Select the dist folder above\n\n"
                    f"Opening Chrome extensions page now...",
                ),
                border_style=BRAND_PRIMARY,
            ),
        )
        osascript_bin = shutil.which("osascript") or "/usr/bin/osascript"
        subprocess.run(  # noqa: S603
            [
                osascript_bin,
                "-e",
                'tell application "Google Chrome" to activate',
                "-e",
                'tell application "Google Chrome" to tell front window to make new tab with properties {URL:"chrome://extensions/"}',
            ],
            check=False,
        )

    else:  # status
        has_dist = dist_dir.exists()
        file_count = len(list(dist_dir.rglob("*"))) if has_dist else 0
        has_npm = shutil.which("npm") is not None

        table = Table(title="AiCippy Extension Status", border_style=BRAND_PRIMARY)
        table.add_column("Property", style=BRAND_SECONDARY)
        table.add_column("Value", style="white")
        table.add_row("Source", str(ext_dir))
        table.add_row(
            "Built",
            f"[{BRAND_SUCCESS}]Yes ({file_count} files)[/]" if has_dist else f"[{BRAND_ERROR}]No[/]",
        )
        table.add_row(
            "npm",
            f"[{BRAND_SUCCESS}]Available[/]" if has_npm else f"[{BRAND_ERROR}]Not found[/]",
        )
        table.add_row("CLI version", __version__)
        console.print(table)


if __name__ == "__main__":
    app()
