"""
Main moneyflow TUI Application.

A fast, keyboard-driven terminal interface for personal finance management.

This is the main application module containing the MoneyflowApp class which:
- Coordinates all UI components (screens, widgets, data table)
- Handles keyboard bindings and user actions
- Manages application state and data loading
- Orchestrates the commit workflow

Architecture:
- UI Layer: This file (Textual screens and widgets)
- Business Logic: Extracted to service classes (ViewPresenter, TimeNavigator, CommitOrchestrator)
- Data Layer: DataManager handles API operations and Polars DataFrames
- State Layer: AppState holds application state

The separation allows business logic to be thoroughly tested while keeping
the UI layer thin and focused on rendering and user interaction.
"""

import argparse
import sys
import traceback
from datetime import date as date_type
from datetime import datetime
from pathlib import Path
from typing import Any, Optional

import polars as pl
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical
from textual.reactive import reactive
from textual.widgets import DataTable, Footer, Header, LoadingIndicator, Static

from .app_controller import AppController
from .backends import DemoBackend, get_backend
from .cache_manager import CacheManager
from .credentials import CredentialManager
from .data_manager import DataManager
from .duplicate_detector import DuplicateDetector
from .logging_config import get_logger, setup_logging
from .notification_helper import NotificationHelper
from .retry_logic import RetryAborted, retry_with_backoff

# Screen imports
from .screens.credential_screens import (
    BackendSelectionScreen,
    CachePromptScreen,
    CredentialSetupScreen,
    CredentialUnlockScreen,
    FilterScreen,
    QuitConfirmationScreen,
)
from .screens.duplicates_screen import DuplicatesScreen
from .screens.edit_screens import DeleteConfirmationScreen, EditMerchantScreen, SelectCategoryScreen
from .screens.review_screen import ReviewChangesScreen
from .screens.search_screen import SearchScreen
from .screens.transaction_detail_screen import TransactionDetailScreen
from .state import AppState, SortDirection, SortMode, ViewMode
from .textual_view import TextualViewPresenter
from .widgets.help_screen import HelpScreen


class MoneyflowApp(App):
    """
    Main application class for the moneyflow terminal UI.

    This Textual application provides a keyboard-driven interface for managing
    personal finance transactions with a focus on power user workflows:

    **Key Features**:
    - Aggregated views (merchant, category, group, account)
    - Drill-down navigation with breadcrumbs
    - Bulk editing with multi-select
    - Time period navigation (year/month with arrow keys)
    - Search and filtering
    - Review-before-commit workflow
    - Offline-first (fetch once, work locally, commit when ready)

    **State Management**:
    - AppState: Holds all application state
    - DataManager: Manages transaction data and API operations
    - Backend: Pluggable backend (MonarchBackend, DemoBackend, etc.)

    **Keyboard Bindings**:
    See BINDINGS class attribute for full list. Key actions:
    - g: Cycle grouping modes
    - u: View all transactions
    - Enter: Drill down
    - Esc: Go back
    - m/r/h/d: Edit operations
    - w: Review and commit
    - ←/→: Navigate time periods
    - y/t/a: Year/month/all time

    **Architecture**:
    Business logic has been extracted to testable service classes:
    - ViewPresenter: Presentation logic (formatting, flags)
    - TimeNavigator: Date calculations
    - CommitOrchestrator: DataFrame updates after commits

    This allows the UI layer to focus on rendering and user interaction
    while keeping complex logic fully tested.
    """

    # Use Path object to properly resolve CSS file location
    # __file__ is moneyflow/app.py, so parent/styles/moneyflow.tcss is correct
    CSS_PATH = str(Path(__file__).parent / "styles" / "moneyflow.tcss")

    BINDINGS = [
        # View mode
        Binding("g", "cycle_grouping", "Group By", show=True),
        Binding("u", "view_ungrouped", "All Txns", show=True),
        Binding("D", "find_duplicates", "Duplicates", show=True, key_display="D"),
        # Hidden direct access bindings (still available in aggregate views, not shown in footer)
        # Note: 'm' conflicts with edit_merchant in detail view, so view_merchants removed
        # Note: 'c' removed - conflicts with commit confirmation in review screen
        Binding("A", "view_accounts", "Accounts", show=False, key_display="A"),
        # Time navigation
        Binding("y", "this_year", "Year", show=True),
        Binding("t", "this_month", "Month", show=True),
        Binding("a", "all_time", "All", show=True),
        # Sorting
        Binding("s", "toggle_sort_field", "Sort", show=True),
        Binding("v", "reverse_sort", "↕ Reverse", show=True),
        # Time navigation with arrows
        Binding("left", "prev_period", "← Prev", show=True),
        Binding("right", "next_period", "→ Next", show=True),
        # Editing
        Binding("m", "edit_merchant", "Edit Merchant", show=False),
        Binding("c", "edit_category", "Edit Category", show=False),
        Binding("d", "delete_transaction", "Delete", show=False),
        Binding("h", "toggle_hide_from_reports", "Hide/Unhide", show=False),
        Binding("i", "show_transaction_details", "Info", show=False),
        Binding("space", "toggle_select", "Select", show=False),
        Binding("ctrl+a", "select_all", "Select All", show=False),
        # Other actions
        Binding("f", "show_filters", "Filters", show=True),
        Binding("question_mark", "help", "Help", show=True, key_display="?"),
        Binding("slash", "search", "Search", show=True, key_display="/"),
        Binding("escape", "go_back", "Back", show=False),
        Binding("w", "review_and_commit", "Commit", show=True),
        Binding("q", "quit_app", "Quit", show=True),
        Binding("ctrl+c", "quit_app", "Force Quit", show=False),  # Also allow Ctrl+C
    ]

    # Reactive state
    status_message = reactive("Ready")
    pending_changes_count = reactive(0)

    def _notify(self, notification_tuple: tuple[str, str, int]) -> None:
        """
        Wrapper for self.notify() that unpacks NotificationHelper tuples.

        Usage:
            self._notify(NotificationHelper.commit_success(10))

        Instead of:
            msg, severity, timeout = NotificationHelper.commit_success(10)
            self.notify(msg, severity=severity, timeout=timeout)
        """
        msg, severity, timeout = notification_tuple
        self.notify(msg, severity=severity, timeout=timeout)

    def __init__(
        self,
        start_year: Optional[int] = None,
        custom_start_date: Optional[str] = None,
        demo_mode: bool = False,
        cache_path: Optional[str] = None,
        force_refresh: bool = False,
        backend: Optional[Any] = None,
        config: Optional[Any] = None,
    ):
        super().__init__()
        self.demo_mode = demo_mode
        self.start_year = start_year

        # Backend configuration (for Amazon/YNAB/etc)
        # Import here to avoid circular dependency
        from moneyflow.backend_config import BackendConfig

        self.backend_config = config or BackendConfig.for_monarch()

        # Backend will be initialized in initialize_data() based on credentials
        # unless explicitly provided (e.g., for Amazon mode)
        self.backend = backend
        if backend is not None:
            # Backend provided externally (Amazon mode, etc.)
            pass
        elif demo_mode:
            self.backend = DemoBackend(year=start_year or 2025)
            self.title = "moneyflow [DEMO MODE]"
        else:
            # Monarch mode - backend will be set in initialize_data()
            self.title = "moneyflow"

        self.data_manager: Optional[DataManager] = None
        self.state = AppState()
        self.loading = False
        self.custom_start_date = custom_start_date
        self.stored_credentials: Optional[dict] = None
        self.cache_path = cache_path
        self.force_refresh = force_refresh
        self.cache_manager = None  # Will be set if caching is enabled
        self.cache_year_filter = None  # Track what filters the cache uses
        self.cache_since_filter = None
        # Controller will be initialized after data_manager is ready
        self.controller: Optional[AppController] = None

    def compose(self) -> ComposeResult:
        """Compose the main UI."""
        yield Header(show_clock=True)

        with Container(id="app-body"):
            # Top status bar
            with Horizontal(id="status-bar"):
                yield Static("", id="breadcrumb")
                yield Static("", id="stats")

            # Main content area
            with Vertical(id="content-area"):
                yield LoadingIndicator(id="loading")
                yield Static("", id="loading-status")
                yield DataTable(id="data-table", cursor_type="row")

            # Bottom action hints
            with Horizontal(id="action-bar"):
                yield Static("", id="action-hints")
                yield Static("", id="pending-changes")

        yield Footer()

    async def on_mount(self) -> None:
        """Initialize the app after mounting."""
        try:
            # Set up data table
            table = self.query_one("#data-table", DataTable)
            table.cursor_type = "row"
            table.zebra_stripes = True

            # Hide loading initially
            self.query_one("#loading", LoadingIndicator).display = False
            self.query_one("#loading-status", Static).display = False

            # Attempt to use saved session or show login prompt
            # Must run in a worker to use push_screen with wait_for_dismiss
            self.run_worker(self.initialize_data(), exclusive=True)
        except Exception as e:
            # Try to show error to user
            try:
                loading_status = self.query_one("#loading-status", Static)
                loading_status.update(f"❌ Startup failed: {e}\n\nPress 'q' to quit")
                loading_status.display = True
            except Exception:
                pass  # UI not ready yet, error will be shown in console
            raise

    def _setup_loading_ui(self):
        """Setup loading UI and return loading status widget."""
        self.loading = True
        self.query_one("#loading", LoadingIndicator).display = True
        loading_status = self.query_one("#loading-status", Static)
        loading_status.display = True
        return loading_status

    def _initialize_managers(self):
        """Initialize data manager, cache manager, and controller."""
        # In demo mode, use a temp directory for merchant cache (don't pollute ~/.moneyflow)
        merchant_cache_dir = "" if not self.demo_mode else "/tmp/moneyflow_demo"
        self.data_manager = DataManager(self.backend, merchant_cache_dir=merchant_cache_dir)

        # Initialize cache manager only if user requested caching
        if self.cache_path is not None:
            self.cache_manager = CacheManager(cache_dir=self.cache_path)

        # Initialize controller with view presenter pattern
        view = TextualViewPresenter(self)
        self.controller = AppController(view, self.state, self.data_manager, self.cache_manager)

    def _determine_date_range(self):
        """Determine date range based on CLI arguments.

        Returns:
            tuple: (start_date, end_date, cache_year_filter, cache_since_filter)
        """
        if self.custom_start_date:
            start_date = self.custom_start_date
            end_date = datetime.now().strftime("%Y-%m-%d")
            cache_year_filter = None
            cache_since_filter = self.custom_start_date
        elif self.start_year:
            start_date = f"{self.start_year}-01-01"
            end_date = datetime.now().strftime("%Y-%m-%d")
            cache_year_filter = self.start_year
            cache_since_filter = None
        else:
            # Fetch ALL transactions (no date filter for offline-first approach)
            start_date = None
            end_date = None
            cache_year_filter = None
            cache_since_filter = None

        return start_date, end_date, cache_year_filter, cache_since_filter

    def _store_data(self, df, categories, category_groups):
        """Store data in data manager and state."""
        self.data_manager.df = df
        self.data_manager.categories = categories
        self.data_manager.category_groups = category_groups
        self.state.transactions_df = df

    def _initialize_view(self):
        """Initialize time frame to THIS_YEAR and show initial view."""

        today = date_type.today()
        self.state.start_date = date_type(today.year, 1, 1)
        self.state.end_date = date_type(today.year, 12, 31)

        # Show initial view (merchants)
        self.refresh_view()

    async def _handle_credentials(self):
        """Handle credential unlock/setup flow.

        Returns:
            dict: Credentials dict or None if user exits
        """

        cred_manager = CredentialManager()

        logger = get_logger(__name__)
        logger.debug(f"Credentials exist: {cred_manager.credentials_exist()}")

        if cred_manager.credentials_exist():
            # Show unlock screen
            result = await self.push_screen(CredentialUnlockScreen(), wait_for_dismiss=True)

            if result is None:
                # User chose to reset - show backend selection then setup screen
                backend_type = await self.push_screen(
                    BackendSelectionScreen(), wait_for_dismiss=True
                )
                if not backend_type:
                    self.exit()
                    return None

                creds = await self.push_screen(
                    CredentialSetupScreen(backend_type=backend_type), wait_for_dismiss=True
                )
                if not creds:
                    self.exit()
                    return None
                return creds
            else:
                return result
        else:
            # No credentials - show backend selection first, then setup screen
            backend_type = await self.push_screen(BackendSelectionScreen(), wait_for_dismiss=True)
            if not backend_type:
                self.exit()
                return None

            creds = await self.push_screen(
                CredentialSetupScreen(backend_type=backend_type), wait_for_dismiss=True
            )
            if not creds:
                self.exit()
                return None
            return creds

    async def _login_with_retry(self, creds, loading_status):
        """Login with retry logic for robustness.

        Args:
            creds: Credentials dict
            loading_status: Loading status widget

        Returns:
            bool: True on success, False on failure
        """

        logger = get_logger(__name__)

        backend_type = creds.get("backend_type", "monarch")
        loading_status.update(f"🔐 Logging in to {backend_type.capitalize()}...")

        logger.debug(f"Starting login flow for {backend_type}")
        logger.debug(f"Email: {creds['email']}")
        logger.debug(f"Has MFA secret: {bool(creds.get('mfa_secret'))}")

        def on_login_retry(attempt: int, wait_seconds: float) -> None:
            """Show retry progress during login."""
            loading_status.update(
                f"⚠ Login failed. Retrying in {wait_seconds:.0f}s (attempt {attempt + 1}/5). Press Ctrl-C to abort."
            )

        async def login_operation():
            """Login with automatic retry on session expiration."""
            try:
                logger.debug("Attempting login with saved session...")
                await self.backend.login(
                    email=creds["email"],
                    password=creds["password"],
                    use_saved_session=True,  # Try saved session first
                    save_session=True,
                    mfa_secret_key=creds["mfa_secret"],
                )
                logger.debug("Login succeeded!")
                return True
            except Exception as e:
                logger.warning(f"Login failed: {e}", exc_info=True)
                error_str = str(e).lower()
                # Check if it's a stale session
                if "401" in error_str or "unauthorized" in error_str:
                    logger.debug("Detected stale session, performing fresh login")
                    # Use centralized fresh login logic
                    await self._do_fresh_login(creds)
                    return True
                # Not a session issue, re-raise for retry logic
                raise

        try:
            await retry_with_backoff(
                operation=login_operation,
                operation_name="Login to backend",
                max_retries=5,
                initial_wait=60.0,
                on_retry=on_login_retry,
            )
            # Store credentials for automatic session refresh if needed
            self.stored_credentials = creds
            loading_status.update("✅ Logged in successfully!")
            logger.debug("Login flow completed successfully")
            return True
        except RetryAborted:
            # User pressed Ctrl-C
            logger.debug("Login cancelled by user")
            loading_status.update("Login cancelled by user. Press 'q' to quit.")
            return False
        except Exception as e:
            # All retries exhausted
            logger.error(f"Login failed after all retries: {e}", exc_info=True)
            error_msg = f"Login failed: {e}"
            loading_status.update(
                f"❌ {error_msg}\n\nCheck ~/.moneyflow/moneyflow.log for details.\n\nPress 'q' to quit"
            )
            return False

    async def _check_and_load_cache(self, loading_status):
        """Check if cache is valid and load from cache if user approves.

        Args:
            loading_status: Loading status widget

        Returns:
            tuple: (df, categories, category_groups) or None if not using cache
        """
        logger = get_logger(__name__)

        use_cache = False
        if (
            self.cache_manager
            and not self.force_refresh
            and self.cache_manager.is_cache_valid(
                year=self.cache_year_filter, since=self.cache_since_filter
            )
        ):
            # Cache is valid - show prompt
            cache_info = self.cache_manager.get_cache_info()
            if cache_info:
                use_cache = await self.push_screen(
                    CachePromptScreen(
                        age=cache_info["age"],
                        transaction_count=cache_info["transaction_count"],
                        filter_desc=cache_info["filter"],
                    ),
                    wait_for_dismiss=True,
                )

        if use_cache:
            # Load from cache
            loading_status.update("📦 Loading from cache...")
            result = self.cache_manager.load_cache()
            if result:
                df, categories, category_groups, metadata = result
                # Apply category grouping dynamically (so CATEGORY_GROUPS changes take effect)
                loading_status.update("🔄 Applying category groupings...")
                df = self.data_manager.apply_category_groups(df)

                # Load merchant cache for autocomplete (especially important for MTD mode)
                try:
                    cached_merchants = await self.data_manager.refresh_merchant_cache(force=False)
                    self.data_manager.all_merchants = cached_merchants
                    logger.debug(f"Loaded {len(cached_merchants)} merchants from cache")
                except Exception as e:
                    logger.warning(f"Merchant cache load failed: {e}")
                    self.data_manager.all_merchants = []

                loading_status.update(f"✅ Loaded {len(df):,} transactions from cache!")
                return df, categories, category_groups
            else:
                # Cache load failed, fall back to API
                loading_status.update("⚠ Cache load failed, fetching from API...")
                return None

        return None

    async def _fetch_data_with_retry(self, creds, start_date, end_date, loading_status):
        """Fetch data from API with retry logic.

        Args:
            creds: Credentials dict (may be None in demo mode)
            start_date: Start date for fetch
            end_date: End date for fetch
            loading_status: Loading status widget

        Returns:
            tuple: (df, categories, category_groups) or None on failure
        """

        logger = get_logger(__name__)

        # Update status based on date range
        if self.custom_start_date:
            loading_status.update(
                f"📊 Fetching transactions from {self.custom_start_date} onwards..."
            )
        elif self.start_year:
            loading_status.update(f"📊 Fetching transactions from {self.start_year} onwards...")
        else:
            loading_status.update("📊 Fetching ALL transaction data from backend...")

        loading_status.update("⏳ This may take a minute for large accounts (10k+ transactions)...")
        loading_status.update(
            "💡 TIP: This is a one-time download. Future operations will be instant!"
        )

        def update_progress(msg: str) -> None:
            """Update the loading status display."""
            loading_status.update(f"📊 {msg}")

        def on_fetch_retry(attempt: int, wait_seconds: float) -> None:
            """Show retry progress during data fetch."""
            loading_status.update(
                f"⚠ Data fetch failed. Retrying in {wait_seconds:.0f}s (attempt {attempt + 1}/5). Press Ctrl-C to abort."
            )

        async def fetch_operation():
            """Fetch data with automatic error logging."""
            try:
                logger.debug(f"Fetching transactions (start={start_date}, end={end_date})")
                result = await self.data_manager.fetch_all_data(
                    start_date=start_date, end_date=end_date, progress_callback=update_progress
                )
                logger.debug(f"Data fetch succeeded - loaded {len(result[0])} transactions")
                return result
            except Exception as e:
                logger.error(f"Data fetch failed: {e}", exc_info=True)
                # Check if session expiration
                error_str = str(e).lower()
                if ("401" in error_str or "unauthorized" in error_str) and creds:
                    logger.info("Session expired during fetch, attempting fresh login...")
                    loading_status.update("🔄 Session expired. Re-authenticating...")
                    # Use centralized fresh login logic
                    try:
                        await self._do_fresh_login(creds)
                        loading_status.update("✅ Re-authenticated. Retrying fetch...")
                        result = await self.data_manager.fetch_all_data(
                            start_date=start_date,
                            end_date=end_date,
                            progress_callback=update_progress,
                        )
                        logger.info(f"Fetch retry succeeded - loaded {len(result[0])} transactions")
                        return result
                    except Exception as reauth_error:
                        logger.error(f"Re-authentication failed: {reauth_error}", exc_info=True)
                        # Re-auth failed, let retry logic handle it with backoff
                        raise Exception(f"Session refresh failed: {reauth_error}")
                # Not auth error, re-raise for retry logic
                raise

        try:
            df, categories, category_groups = await retry_with_backoff(  # type: ignore
                operation=fetch_operation,
                operation_name="Fetch transaction data",
                max_retries=5,
                initial_wait=60.0,
                on_retry=on_fetch_retry,
            )

            # Save to cache for next time (only if --cache was passed)
            if self.cache_manager:
                loading_status.update("💾 Saving to cache...")
                self.cache_manager.save_cache(
                    transactions_df=df,
                    categories=categories,
                    category_groups=category_groups,
                    year=self.cache_year_filter,
                    since=self.cache_since_filter,
                )
                loading_status.update(f"✅ Loaded {len(df):,} transactions and cached!")
            else:
                loading_status.update(f"✅ Loaded {len(df):,} transactions!")

            return df, categories, category_groups
        except RetryAborted:
            logger.debug("Data fetch cancelled by user")
            loading_status.update("Data fetch cancelled. Press 'q' to quit.")
            return None
        except Exception as e:
            logger.error(f"Data fetch failed after all retries: {e}", exc_info=True)
            loading_status.update(
                f"❌ Failed to load data: {e}\n\nCheck ~/.moneyflow/moneyflow.log for details.\n\nPress 'q' to quit"
            )
            return None

    async def _handle_init_error(self, error, loading_status):
        """Handle initialization errors.

        Args:
            error: The exception that occurred
            loading_status: Loading status widget
        """

        logger = get_logger(__name__)

        error_str = str(error).lower()

        # Check if it's a 401/unauthorized error
        if "401" in error_str or "unauthorized" in error_str:
            logger.error("401/Unauthorized in outer handler - recovery already attempted")
            # If we get here, session recovery already failed in the fetch block above
            # Delete the bad session
            try:
                if self.backend:
                    self.backend.delete_session()
                    logger.debug("Session deleted")
            except Exception as del_err:
                logger.error(f"Failed to delete session: {del_err}")

            # Show helpful error
            loading_status.update(
                "❌ Session error.\n\n"
                "Could not authenticate with backend.\n"
                "Please restart the app to login fresh.\n\n"
                "Press 'q' to quit"
            )
        else:
            error_msg = f"Failed to load data: {error}"
            loading_status.update(f"❌ {error_msg}\n\nPress 'q' to quit")

        # Log detailed error for debugging
        logger.error(f"DATA LOADING ERROR: {error} (Type: {type(error).__name__})", exc_info=True)

    async def initialize_data(self) -> None:
        """
        Load data from backend API or cache.

        This is the main orchestrator for data initialization. It coordinates:
        1. Credential handling (unlock/setup)
        2. Backend login with retry logic
        3. Cache checking and loading
        4. Data fetching from API with retry logic
        5. Data storage and view initialization
        6. Error handling and cleanup
        """

        logger = get_logger(__name__)
        logger.debug("initialize_data started")
        has_error = False  # Track if we encountered an error

        # Setup loading UI
        try:
            loading_status = self._setup_loading_ui()
        except Exception as e:
            logger.error(f"Failed to initialize UI: {e}", exc_info=True)
            raise

        # Set initial status
        if self.demo_mode:
            loading_status.update("🎮 DEMO MODE - Loading sample data...")
        else:
            loading_status.update("🔄 Connecting to backend...")

        try:
            # Step 1: Handle credentials (only if backend requires auth)
            creds = None
            if not self.demo_mode and self.backend_config.requires_auth:
                creds = await self._handle_credentials()
                if creds is None:
                    return  # User exited

                # Initialize backend based on credentials
                backend_type = creds.get("backend_type", "monarch")
                loading_status.update(f"🔄 Initializing {backend_type} backend...")
                self.backend = get_backend(backend_type)

                # Step 2: Login with retry logic
                login_success = await self._login_with_retry(creds, loading_status)
                if not login_success:
                    has_error = True
                    return
            else:
                # No authentication needed (demo mode or local backend like Amazon)
                if self.demo_mode:
                    loading_status.update("🎮 DEMO MODE - No authentication required")
                else:
                    loading_status.update(f"📂 Using local {self.backend_config.backend_type} data...")
                await self.backend.login()  # No-op for backends without auth

            # Step 3: Initialize managers
            self._initialize_managers()

            # Step 4: Determine date range
            start_date, end_date, self.cache_year_filter, self.cache_since_filter = (
                self._determine_date_range()
            )

            # Step 5: Check and load cache
            cached_data = await self._check_and_load_cache(loading_status)

            if cached_data:
                df, categories, category_groups = cached_data
            else:
                # Step 6: Fetch from API with retry logic
                fetch_result = await self._fetch_data_with_retry(
                    creds, start_date, end_date, loading_status
                )
                if fetch_result is None:
                    has_error = True
                    return
                df, categories, category_groups = fetch_result

            # Step 7: Store data
            self._store_data(df, categories, category_groups)

            # Step 8: Initialize view
            loading_status.update(f"✅ Ready! Showing {len(df):,} transactions")
            self._initialize_view()

        except Exception as e:
            await self._handle_init_error(e, loading_status)
            has_error = True

        finally:
            self.loading = False
            self.query_one("#loading", LoadingIndicator).display = False
            # DON'T hide loading-status if we had an error
            if not has_error:
                self.query_one("#loading-status", Static).display = False
            # If there was an error, keep the error message visible

    def update_loading_progress(self, current: int, total: int, message: str) -> None:
        """Update loading progress message."""
        self.status_message = f"{message} ({current}/{total})"

    def _save_table_position(self) -> dict:
        """
        Save current table cursor and scroll position.

        Returns:
            Dict with cursor_row and scroll_y
        """
        try:
            table = self.query_one("#data-table", DataTable)
            return {
                "cursor_row": table.cursor_row,
                "scroll_y": table.scroll_y,
            }
        except Exception:
            return {"cursor_row": 0, "scroll_y": 0}

    def _restore_table_position(self, saved_position: dict) -> None:
        """
        Restore table cursor and scroll position after refresh.

        Args:
            saved_position: Dict from _save_table_position()
        """
        try:
            table = self.query_one("#data-table", DataTable)
            cursor_row = saved_position.get("cursor_row", 0)
            scroll_y = saved_position.get("scroll_y", 0)

            # Restore cursor (bounded by current row count)
            if cursor_row < table.row_count:
                table.move_cursor(row=cursor_row)

            # Restore scroll position
            table.scroll_y = scroll_y
        except Exception:
            pass  # Table might not be ready yet

    def refresh_view(self, force_rebuild: bool = True) -> None:
        """
        Refresh the current view based on state.

        Delegates to AppController which handles all business logic.
        This method is now just a thin wrapper for backwards compatibility.

        Args:
            force_rebuild: If True, clear columns and rebuild entire table.
                          If False, only update rows (avoids flash when staying in same view).
        """
        if self.controller is None:
            return

        # Delegate to controller - it handles all the business logic
        self.controller.refresh_view(force_rebuild=force_rebuild)

    # Actions
    def action_view_merchants(self) -> None:
        """Switch to merchant view."""
        self.controller.switch_to_merchant_view()

    def action_view_categories(self) -> None:
        """Switch to category view."""
        self.controller.switch_to_category_view()

    def action_view_groups(self) -> None:
        """Switch to group view."""
        self.controller.switch_to_group_view()

    def action_view_accounts(self) -> None:
        """Switch to account view."""
        self.controller.switch_to_account_view()

    def action_cycle_grouping(self) -> None:
        """
        Cycle through grouping views.

        If drilled down: Cycle sub-groupings (Category/Group/Account/Detail)
        If not drilled down: Cycle top-level views (Merchant/Category/Group/Account)
        """
        view_name = self.controller.cycle_grouping()
        if view_name:
            self._notify(NotificationHelper.view_changed(view_name))

    def action_view_ungrouped(self) -> None:
        """Switch to ungrouped transactions view (all transactions in reverse chronological order)."""
        self.controller.switch_to_detail_view(set_default_sort=True)
        self._notify(NotificationHelper.all_transactions_view())

    def action_find_duplicates(self) -> None:
        """Find and display duplicate transactions."""
        if self.data_manager is None or self.data_manager.df is None:
            return
        # Find duplicates in current filtered view
        filtered_df = self.state.get_filtered_df()
        if filtered_df is None or filtered_df.is_empty():
            self.notify("No transactions to check", timeout=2)
            return

        self.notify("Scanning for duplicates...", timeout=1)
        duplicates = DuplicateDetector.find_duplicates(filtered_df)

        if duplicates.is_empty():
            self.notify("✅ No duplicates found!", severity="information", timeout=3)
        else:
            groups = DuplicateDetector.get_duplicate_groups(filtered_df, duplicates)
            # Show duplicates screen
            self.push_screen(DuplicatesScreen(duplicates, groups, filtered_df))

    # Time navigation actions
    def action_this_year(self) -> None:
        """Switch to current year view."""
        self.controller.set_timeframe_this_year()
        self.notify("Viewing: This Year", timeout=1)

    def action_all_time(self) -> None:
        """Switch to all time view."""
        self.controller.set_timeframe_all_time()
        self.notify("Viewing: All Time", timeout=1)

    def action_this_month(self) -> None:
        """Switch to current month view."""
        self.controller.set_timeframe_this_month()
        self.notify("Viewing: This Month", timeout=1)

    def _select_month(self, month: int, month_name: str) -> None:
        """Helper to select a specific month of the current year."""
        description = self.controller.select_month(month)
        self.notify(f"Viewing: {description}", timeout=1)

    def action_prev_period(self) -> None:
        """Navigate to previous time period."""
        should_fallback, description = self.controller.navigate_prev_period()

        if should_fallback:
            # In all-time view, go to current year
            self.action_this_year()
        else:
            self.notify(f"Viewing: {description}", timeout=1)

    def action_next_period(self) -> None:
        """Navigate to next time period."""
        should_fallback, description = self.controller.navigate_next_period()

        if should_fallback:
            # In all-time view, go to current year
            self.action_this_year()
        else:
            self.notify(f"Viewing: {description}", timeout=1)

    def action_reverse_sort(self) -> None:
        """Reverse the current sort direction."""
        direction = self.controller.reverse_sort()
        self.notify(f"Sort: {direction}", timeout=1)

    def action_toggle_sort_field(self) -> None:
        """Toggle sorting field."""
        field_name = self.controller.toggle_sort_field()
        self.notify(f"Sorting by: {field_name}", timeout=1)

    def action_show_filters(self) -> None:
        """Show filter options modal."""
        self.run_worker(self._show_filter_modal(), exclusive=False)

    async def _show_filter_modal(self) -> None:
        """Show filter modal and apply selected filters."""
        result = await self.push_screen(
            FilterScreen(
                show_transfers=self.state.show_transfers, show_hidden=self.state.show_hidden
            ),
            wait_for_dismiss=True,
        )

        if result is not None:
            # Apply filters via controller
            self.controller.apply_filters(
                show_transfers=result["show_transfers"], show_hidden=result["show_hidden"]
            )

            # Build status message
            statuses = []
            if result["show_hidden"]:
                statuses.append("hidden items shown")
            else:
                statuses.append("hidden items excluded")
            if result["show_transfers"]:
                statuses.append("transfers shown")
            else:
                statuses.append("transfers excluded")

            self.notify(f"Filters: {', '.join(statuses)}", timeout=3)

    def action_help(self) -> None:
        """Show help screen."""
        self.push_screen(HelpScreen())

    def action_search(self) -> None:
        """Show search input with live filtering."""
        self.run_worker(self._show_search(), exclusive=False)

    async def _show_search(self) -> None:
        """Show search modal and apply filter."""
        # Show search modal with current query
        new_query = await self.push_screen(
            SearchScreen(current_query=self.state.search_query), wait_for_dismiss=True
        )

        if new_query is not None:  # None means cancelled
            # Apply search via controller
            if new_query:
                count = self.controller.apply_search(new_query)
                self.notify(f"Search: '{new_query}' - {count} results", timeout=2)
            else:
                self.controller.clear_search()
                self.notify("Search cleared", timeout=1)

    def action_toggle_select(self) -> None:
        """Toggle selection of current row for bulk operations."""
        if self.data_manager is None or self.state.current_data is None:
            return

        table = self.query_one("#data-table", DataTable)
        if table.cursor_row < 0:
            return

        # Save cursor and scroll position
        saved_position = self._save_table_position()

        row_data = self.state.current_data.row(table.cursor_row, named=True)

        # Check if we're in aggregate view or detail view
        if self.state.view_mode in [
            ViewMode.MERCHANT,
            ViewMode.CATEGORY,
            ViewMode.GROUP,
            ViewMode.ACCOUNT,
        ]:
            # Aggregate view - toggle group selection
            # Get the group name from first column
            group_name = str(row_data.get(self.state.current_data.columns[0]))
            self.state.toggle_group_selection(group_name)
            count = len(self.state.selected_group_keys)
            # Refresh view to show checkmark
            self.refresh_view()
            # Restore cursor and scroll position
            self._restore_table_position(saved_position)
            self.notify(f"Selected: {count} group(s)", timeout=1)

        elif (
            self.state.view_mode == ViewMode.DETAIL
            and self.state.is_drilled_down()
            and self.state.sub_grouping_mode
        ):
            # Sub-grouped view - toggle group selection
            group_name = str(row_data.get(self.state.current_data.columns[0]))
            self.state.toggle_group_selection(group_name)
            count = len(self.state.selected_group_keys)
            self.refresh_view()
            self._restore_table_position(saved_position)
            self.notify(f"Selected: {count} group(s)", timeout=1)

        else:
            # Detail view - toggle transaction selection
            txn_id = row_data.get("id")
            if txn_id:
                self.state.toggle_selection(txn_id)
                count = len(self.state.selected_ids)
                # Refresh view to show checkmark
                self.refresh_view()
                # Restore cursor and scroll position
                self._restore_table_position(saved_position)
                self.notify(f"Selected: {count} transaction(s)", timeout=1)

    def action_select_all(self) -> None:
        """Toggle select all / deselect all rows in the current view."""
        if self.data_manager is None or self.state.current_data is None:
            return

        table = self.query_one("#data-table", DataTable)
        saved_cursor_row = table.cursor_row if table.cursor_row >= 0 else 0
        total_rows = len(self.state.current_data)

        # Check if we're in aggregate view or detail view
        if self.state.view_mode in [
            ViewMode.MERCHANT,
            ViewMode.CATEGORY,
            ViewMode.GROUP,
            ViewMode.ACCOUNT,
        ]:
            # Aggregate view - toggle all groups
            # Check if all groups are already selected
            all_selected = len(self.state.selected_group_keys) == total_rows

            if all_selected:
                # Deselect all
                self.state.selected_group_keys.clear()
                self.refresh_view()
                if saved_cursor_row < table.row_count:
                    table.move_cursor(row=saved_cursor_row)
                self.notify("Deselected all groups", timeout=2)
            else:
                # Select all
                self.state.selected_group_keys.clear()
                for row_idx in range(total_rows):
                    row_data = self.state.current_data.row(row_idx, named=True)
                    group_name = str(row_data.get(self.state.current_data.columns[0]))
                    self.state.selected_group_keys.add(group_name)
                count = len(self.state.selected_group_keys)
                self.refresh_view()
                if saved_cursor_row < table.row_count:
                    table.move_cursor(row=saved_cursor_row)
                self.notify(f"Selected all {count} group(s)", timeout=2)

        elif (
            self.state.view_mode == ViewMode.DETAIL
            and self.state.is_drilled_down()
            and self.state.sub_grouping_mode
        ):
            # Sub-grouped view - toggle all groups
            all_selected = len(self.state.selected_group_keys) == total_rows

            if all_selected:
                # Deselect all
                self.state.selected_group_keys.clear()
                self.refresh_view()
                if saved_cursor_row < table.row_count:
                    table.move_cursor(row=saved_cursor_row)
                self.notify("Deselected all groups", timeout=2)
            else:
                # Select all
                self.state.selected_group_keys.clear()
                for row_idx in range(total_rows):
                    row_data = self.state.current_data.row(row_idx, named=True)
                    group_name = str(row_data.get(self.state.current_data.columns[0]))
                    self.state.selected_group_keys.add(group_name)
                count = len(self.state.selected_group_keys)
                self.refresh_view()
                if saved_cursor_row < table.row_count:
                    table.move_cursor(row=saved_cursor_row)
                self.notify(f"Selected all {count} group(s)", timeout=2)

        else:
            # Detail view - toggle all transactions
            all_selected = len(self.state.selected_ids) == total_rows

            if all_selected:
                # Deselect all
                self.state.selected_ids.clear()
                self.refresh_view()
                if saved_cursor_row < table.row_count:
                    table.move_cursor(row=saved_cursor_row)
                self.notify("Deselected all transactions", timeout=2)
            else:
                # Select all
                self.state.selected_ids.clear()
                for row_idx in range(total_rows):
                    row_data = self.state.current_data.row(row_idx, named=True)
                    txn_id = row_data.get("id")
                    if txn_id:
                        self.state.selected_ids.add(txn_id)
                count = len(self.state.selected_ids)
                self.refresh_view()
                if saved_cursor_row < table.row_count:
                    table.move_cursor(row=saved_cursor_row)
                self.notify(f"Selected all {count} transaction(s)", timeout=2)

    def action_edit_merchant(self) -> None:
        """Edit merchant name for current selection."""
        if self.data_manager is None:
            return

        # Check if in aggregate view, sub-grouped view, or detail view
        if self.state.view_mode in [ViewMode.MERCHANT, ViewMode.CATEGORY, ViewMode.GROUP]:
            # Aggregate view - edit all transactions for this merchant
            self.run_worker(self._bulk_edit_merchant_from_aggregate(), exclusive=False)
        elif self.state.view_mode == ViewMode.DETAIL and self.state.sub_grouping_mode:
            # Sub-grouped view - treat like aggregate edit
            self.run_worker(self._bulk_edit_merchant_from_aggregate(), exclusive=False)
        else:
            # Detail view - edit selected transaction(s)
            self.run_worker(self._edit_merchant_detail(), exclusive=False)

    async def _bulk_edit_merchant_from_aggregate(self) -> None:
        """Edit merchant for all transactions in selected aggregate row(s)."""
        if self.state.current_data is None:
            return

        # Check if multi-select is active
        if len(self.state.selected_group_keys) > 0:
            # Multi-select: edit all transactions from all selected groups
            await self._bulk_edit_merchant_from_selected_groups()
            return

        table = self.query_one("#data-table", DataTable)
        if table.cursor_row < 0:
            return

        # Get the merchant/category/group from current row
        row_data = self.state.current_data.row(table.cursor_row, named=True)

        # Determine what field we're grouping by
        # If in sub-grouping mode, use that instead of view_mode
        grouping_mode = self.state.sub_grouping_mode if self.state.sub_grouping_mode else self.state.view_mode

        if grouping_mode == ViewMode.MERCHANT:
            merchant_name = row_data["merchant"]
            transaction_count = row_data["count"]
            total_amount = row_data["total"]

            # Get list of all merchants for suggestions (includes cached + current)
            all_merchants = self.controller.get_merchant_suggestions()

            # Pass aggregate summary for bulk edit
            bulk_summary = {
                "total_amount": total_amount,
            }

            # Show edit modal
            new_merchant = await self.push_screen(
                EditMerchantScreen(merchant_name, transaction_count, all_merchants, bulk_summary),
                wait_for_dismiss=True,
            )

            if new_merchant:
                # Save cursor and scroll position before refresh
                saved_position = self._save_table_position()

                # Get all transactions for this merchant
                filtered_df = self.state.get_filtered_df()
                merchant_txns = self.data_manager.filter_by_merchant(filtered_df, merchant_name)

                # Use controller helper to queue edits
                count = self.controller.queue_merchant_edits(
                    merchant_txns, merchant_name, new_merchant
                )

                self._notify(NotificationHelper.edit_queued(count))
                self.refresh_view()

                # Restore cursor and scroll position
                self._restore_table_position(saved_position)
        elif grouping_mode in [ViewMode.CATEGORY, ViewMode.GROUP, ViewMode.ACCOUNT]:
            # Grouped by category/group/account - edit merchant for all in this group
            if grouping_mode == ViewMode.CATEGORY:
                field_name = row_data["category"]
                filter_func = self.data_manager.filter_by_category
            elif grouping_mode == ViewMode.GROUP:
                field_name = row_data["group"]
                filter_func = self.data_manager.filter_by_group
            elif grouping_mode == ViewMode.ACCOUNT:
                field_name = row_data["account"]
                filter_func = self.data_manager.filter_by_account
            else:
                return

            transaction_count = row_data["count"]
            total_amount = row_data["total"]

            # Determine current merchant name for display
            # If drilled into a specific merchant, show that
            # Otherwise show "Multiple merchants" since we're editing across merchants
            if self.state.selected_merchant:
                display_merchant = self.state.selected_merchant
            elif self.state.selected_category:
                display_merchant = f"Multiple merchants in {self.state.selected_category}"
            elif self.state.selected_group:
                display_merchant = f"Multiple merchants in {self.state.selected_group}"
            elif self.state.selected_account:
                display_merchant = f"Multiple merchants in {self.state.selected_account}"
            else:
                display_merchant = "Multiple merchants"

            # Get merchant suggestions
            all_merchants = self.controller.get_merchant_suggestions()

            # Pass aggregate summary
            bulk_summary = {"total_amount": total_amount}

            # Show edit modal
            new_merchant = await self.push_screen(
                EditMerchantScreen(display_merchant, transaction_count, all_merchants, bulk_summary),
                wait_for_dismiss=True,
            )

            if new_merchant:
                # Save cursor and scroll position before refresh
                saved_position = self._save_table_position()

                # Get all transactions in this group
                filtered_df = self.state.get_filtered_df()
                matching_txns = filter_func(filtered_df, field_name)

                # Use controller helper to queue edits
                count = self.controller.queue_merchant_edits(matching_txns, field_name, new_merchant)

                self._notify(NotificationHelper.edit_queued(count))
                self.refresh_view()

                # Restore cursor and scroll position
                self._restore_table_position(saved_position)
        else:
            self.notify("Edit merchant only works from Merchant view", timeout=2)

    async def _bulk_edit_merchant_from_selected_groups(self) -> None:
        """Edit merchant for all transactions in all selected groups."""
        # Determine which field we're grouping by
        field_map = {
            ViewMode.MERCHANT: "merchant",
            ViewMode.CATEGORY: "category",
            ViewMode.GROUP: "group",
            ViewMode.ACCOUNT: "account",
        }

        # Check if we're in sub-grouped view
        if self.state.is_drilled_down() and self.state.sub_grouping_mode:
            group_field = field_map.get(self.state.sub_grouping_mode, "merchant")
        else:
            group_field = field_map.get(self.state.view_mode, "merchant")

        # Get all transactions from selected groups
        all_txns = self.controller.get_transactions_from_selected_groups(group_field)

        if all_txns.is_empty():
            self.notify("No transactions in selected groups", timeout=2)
            return

        total_count = len(all_txns)

        # Get merchant suggestions
        all_merchants = self.controller.get_merchant_suggestions()

        # Show edit modal
        new_merchant = await self.push_screen(
            EditMerchantScreen(
                f"{len(self.state.selected_group_keys)} groups",
                total_count,
                all_merchants,
            ),
            wait_for_dismiss=True,
        )

        if new_merchant:
            # Save cursor and scroll position before refresh
            saved_position = self._save_table_position()

            # Queue edits for all transactions
            count = self.controller.queue_merchant_edits(all_txns, "multiple", new_merchant)

            self.state.clear_selection()
            self._notify(NotificationHelper.edit_queued(count))
            self.refresh_view()

            # Restore cursor and scroll position
            self._restore_table_position(saved_position)

    async def _edit_merchant_detail(self) -> None:
        """Edit merchant in detail view."""
        if self.state.current_data is None:
            return

        table = self.query_one("#data-table", DataTable)
        if table.cursor_row < 0:
            return

        # Get current transaction
        row_data = self.state.current_data.row(table.cursor_row, named=True)
        current_merchant = row_data["merchant"]

        # Get list of all merchants for suggestions (includes cached + current)
        all_merchants = self.controller.get_merchant_suggestions()

        # Check if we have selected transactions for bulk edit
        if len(self.state.selected_ids) > 0:
            # Bulk edit selected transactions
            new_merchant = await self.push_screen(
                EditMerchantScreen(current_merchant, len(self.state.selected_ids), all_merchants),
                wait_for_dismiss=True,
            )

            if new_merchant:
                # Save cursor and scroll position before refresh
                saved_position = self._save_table_position()

                # Use controller helper to queue edits for all selected transactions
                selected_txns = self.state.current_data.filter(
                    pl.col("id").is_in(list(self.state.selected_ids))
                )
                count = self.controller.queue_merchant_edits(
                    selected_txns, current_merchant, new_merchant
                )

                self.state.clear_selection()
                self.notify(f"Queued {count} edits. Press w to review and commit.", timeout=3)
                # Refresh to update the * markers but stay in current view
                self.refresh_view()

                # Restore cursor and scroll position
                self._restore_table_position(saved_position)
        else:
            # Edit single transaction - pass details for context
            txn_details = {
                "date": row_data.get("date"),
                "amount": row_data.get("amount"),
                "category": row_data.get("category"),
            }

            new_merchant = await self.push_screen(
                EditMerchantScreen(current_merchant, 1, all_merchants, txn_details),
                wait_for_dismiss=True,
            )

            if new_merchant:
                # Save cursor and scroll position before refresh
                saved_position = self._save_table_position()

                # Use controller helper for consistency
                txn_id = row_data["id"]
                single_txn = self.state.current_data.filter(pl.col("id") == txn_id)
                self.controller.queue_merchant_edits(single_txn, current_merchant, new_merchant)

                self._notify(NotificationHelper.merchant_changed())
                # Refresh to show * marker, stays in detail view since view_mode unchanged
                self.refresh_view()

                # Restore cursor and scroll position
                self._restore_table_position(saved_position)

    def action_edit_category(self) -> None:
        """Change category for current selection (works in aggregate and detail views)."""

        logger = get_logger(__name__)

        if self.data_manager is None:
            return

        logger.debug(f"action_edit_category called, view_mode={self.state.view_mode}")

        # Check if in aggregate view (MERCHANT, CATEGORY or GROUP) or detail view
        if self.state.view_mode in [ViewMode.MERCHANT, ViewMode.CATEGORY, ViewMode.GROUP]:
            logger.debug("Calling _bulk_edit_category_from_aggregate()")
            # Aggregate view - edit_category all transactions for this merchant/category/group
            self.run_worker(self._bulk_edit_category_from_aggregate(), exclusive=False)
        else:
            logger.debug(
                f"Calling _edit_category() - view_mode {self.state.view_mode} not in aggregate views"
            )
            # Detail view - edit_category selected transaction(s)
            self.run_worker(self._edit_category(), exclusive=False)

    async def _bulk_edit_category_from_aggregate(self) -> None:
        """Edit Category all transactions in selected merchant/category/group."""
        logger = get_logger(__name__)

        logger.debug(f"_bulk_edit_category_from_aggregate called, view_mode={self.state.view_mode}, sub_grouping_mode={self.state.sub_grouping_mode}")

        # Check if multi-select is active
        if len(self.state.selected_group_keys) > 0:
            # Multi-select: edit all transactions from all selected groups
            await self._bulk_edit_category_from_selected_groups()
            return

        if self.state.current_data is None:
            logger.warning("current_data is None, returning")
            return

        table = self.query_one("#data-table", DataTable)
        if table.cursor_row < 0:
            logger.warning(f"cursor_row < 0 ({table.cursor_row}), returning")
            return

        # Get the merchant/category/group from current row
        row_data = self.state.current_data.row(table.cursor_row, named=True)
        logger.debug(f"row_data keys: {list(row_data.keys())}")

        # Determine what field we're grouping by
        # If in sub-grouping mode, use that instead of view_mode
        grouping_mode = self.state.sub_grouping_mode if self.state.sub_grouping_mode else self.state.view_mode

        # Determine what field we're grouping by and get transactions
        if grouping_mode == ViewMode.MERCHANT:
            field_name = row_data["merchant"]
            current_category_id = None  # Merchants can have mixed categories
            filter_func = self.data_manager.filter_by_merchant
        elif grouping_mode == ViewMode.CATEGORY:
            field_name = row_data["category"]
            current_category_id = row_data["category_id"]
            filter_func = self.data_manager.filter_by_category
        elif grouping_mode == ViewMode.GROUP:
            field_name = row_data["group"]
            current_category_id = None  # Groups can have mixed categories
            filter_func = self.data_manager.filter_by_group
        elif grouping_mode == ViewMode.ACCOUNT:
            field_name = row_data["account"]
            current_category_id = None  # Accounts can have mixed categories
            filter_func = self.data_manager.filter_by_account
        else:
            return

        # Show category selection modal
        new_category_id = await self.push_screen(
            SelectCategoryScreen(
                self.data_manager.categories,
                current_category_id,
                None,  # No transaction details for bulk operations
            ),
            wait_for_dismiss=True,
        )

        # If user cancelled or selected same category, do nothing
        if not new_category_id or (current_category_id and new_category_id == current_category_id):
            return

        # Save cursor and scroll position before refresh
        saved_position = self._save_table_position()

        # Get all transactions for this merchant/category/group
        filtered_df = self.state.get_filtered_df()
        matching_txns = filter_func(filtered_df, field_name)

        # Use controller helper to queue edits
        count = self.controller.queue_category_edits(matching_txns, new_category_id)

        # Show success notification
        new_cat_name = self.data_manager.categories.get(new_category_id, {}).get("name", "Unknown")
        self.notify(
            f"Queued {count} transactions from {field_name} to edit_category to {new_cat_name}. Press w to commit.",
            timeout=3,
        )
        self.refresh_view()

        # Restore cursor and scroll position
        self._restore_table_position(saved_position)

    async def _bulk_edit_category_from_selected_groups(self) -> None:
        """Edit category for all transactions in all selected groups."""
        # Determine which field we're grouping by
        field_map = {
            ViewMode.MERCHANT: "merchant",
            ViewMode.CATEGORY: "category",
            ViewMode.GROUP: "group",
            ViewMode.ACCOUNT: "account",
        }

        # Check if we're in sub-grouped view
        if self.state.is_drilled_down() and self.state.sub_grouping_mode:
            group_field = field_map.get(self.state.sub_grouping_mode, "merchant")
        else:
            group_field = field_map.get(self.state.view_mode, "merchant")

        # Get all transactions from selected groups
        all_txns = self.controller.get_transactions_from_selected_groups(group_field)

        if all_txns.is_empty():
            self.notify("No transactions in selected groups", timeout=2)
            return

        # Show category selection modal
        new_category_id = await self.push_screen(
            SelectCategoryScreen(
                self.data_manager.categories,
                None,  # No single category (multiple groups)
                None,  # No transaction details for bulk
            ),
            wait_for_dismiss=True,
        )

        if not new_category_id:
            return

        # Save cursor and scroll position before refresh
        saved_position = self._save_table_position()

        # Queue edits for all transactions
        count = self.controller.queue_category_edits(all_txns, new_category_id)

        self.state.clear_selection()
        new_cat_name = self.data_manager.categories.get(new_category_id, {}).get("name", "Unknown")
        self.notify(
            f"Queued {count} transactions from {len(self.state.selected_group_keys)} groups to {new_cat_name}. Press w to commit.",
            timeout=3,
        )
        self.refresh_view()

        # Restore cursor and scroll position
        self._restore_table_position(saved_position)

    async def _edit_category(self) -> None:
        """Show category selection and apply (for detail view)."""
        if self.state.current_data is None:
            return

        table = self.query_one("#data-table", DataTable)
        if table.cursor_row < 0:
            return

        # Check if we're in a sub-grouped view (aggregate view while drilled down)
        if self.state.view_mode == ViewMode.DETAIL and self.state.sub_grouping_mode:
            # Sub-grouped view - treat like aggregate edit
            await self._bulk_edit_category_from_aggregate()
            return

        # In detail view, categorize current transaction or selected transactions
        if self.state.view_mode == ViewMode.DETAIL:
            row_data = self.state.current_data.row(table.cursor_row, named=True)

            # Check if multi-select is active
            if len(self.state.selected_ids) > 0:
                # Multi-select edit_category
                # Show category selection (no transaction details for bulk)
                new_category_id = await self.push_screen(
                    SelectCategoryScreen(
                        self.data_manager.categories,
                        row_data["category_id"],
                        None,  # No single transaction details for bulk operation
                    ),
                    wait_for_dismiss=True,
                )

                if new_category_id:
                    # Save cursor and scroll position before refresh
                    saved_position = self._save_table_position()

                    # Use controller helper to queue edits for all selected transactions
                    selected_txns = self.state.current_data.filter(
                        pl.col("id").is_in(list(self.state.selected_ids))
                    )
                    count = self.controller.queue_category_edits(selected_txns, new_category_id)

                    self.state.clear_selection()
                    self.notify(
                        f"Queued {count} category changes. Press w to review and commit.",
                        timeout=3,
                    )
                    self.refresh_view()

                    # Restore cursor and scroll position
                    self._restore_table_position(saved_position)
            else:
                # Single transaction edit_category
                # Pass transaction details for context
                txn_details = {
                    "date": row_data.get("date"),
                    "amount": row_data.get("amount"),
                    "merchant": row_data.get("merchant"),
                }

                # Show category selection
                new_category_id = await self.push_screen(
                    SelectCategoryScreen(
                        self.data_manager.categories, row_data["category_id"], txn_details
                    ),
                    wait_for_dismiss=True,
                )

                if new_category_id:
                    # Save cursor and scroll position before refresh
                    saved_position = self._save_table_position()

                    # Use controller helper to queue edit for single transaction
                    txn_id = row_data["id"]
                    single_txn = self.state.current_data.filter(pl.col("id") == txn_id)
                    self.controller.queue_category_edits(single_txn, new_category_id)

                    self.notify("Category changed. Press w to review and commit.", timeout=2)
                    # Refresh to show * marker, stays in detail view since view_mode unchanged
                    self.refresh_view()

                    # Restore cursor and scroll position
                    self._restore_table_position(saved_position)
        else:
            self.notify("Edit Category only works in transaction detail view", timeout=2)

    def action_toggle_hide_from_reports(self) -> None:
        """Toggle hide from reports flag for current transaction(s) or selected groups."""
        if self.data_manager is None or self.state.current_data is None:
            return

        table = self.query_one("#data-table", DataTable)
        if table.cursor_row < 0:
            return

        # Handle aggregate/subgroup views (bulk hide for groups)
        if self.state.view_mode in [ViewMode.MERCHANT, ViewMode.CATEGORY, ViewMode.GROUP, ViewMode.ACCOUNT]:
            # Determine the field to filter by based on view mode
            field_map = {
                ViewMode.MERCHANT: "merchant",
                ViewMode.CATEGORY: "category",
                ViewMode.GROUP: "group",
                ViewMode.ACCOUNT: "account",
            }
            group_by_field = field_map[self.state.view_mode]

            # Check if multi-select is active
            if len(self.state.selected_group_keys) > 0:
                # Multi-select: get transactions from all selected groups
                transactions_to_toggle = self.controller.get_transactions_from_selected_groups(group_by_field)
                self.state.clear_selection()
            else:
                # Single selection: get transactions from current row
                row_data = self.state.current_data.row(table.cursor_row, named=True)
                group_name = str(row_data.get(self.state.current_data.columns[0]))

                # Get all transactions for this group
                filtered_df = self.state.get_filtered_df()
                if filtered_df is None:
                    return

                if group_by_field == "merchant":
                    transactions_to_toggle = self.data_manager.filter_by_merchant(filtered_df, group_name)
                elif group_by_field == "category":
                    transactions_to_toggle = self.data_manager.filter_by_category(filtered_df, group_name)
                elif group_by_field == "group":
                    transactions_to_toggle = self.data_manager.filter_by_group(filtered_df, group_name)
                elif group_by_field == "account":
                    transactions_to_toggle = self.data_manager.filter_by_account(filtered_df, group_name)

            # Queue hide toggle for all transactions
            count = self.controller.queue_hide_toggle_edits(transactions_to_toggle)
            self.notify(
                f"Toggled hide/unhide for {count} transactions. Press w to commit.",
                timeout=3,
            )
            self.refresh_view()
            return

        # Handle subgrouped detail view (when drilled down with sub-grouping)
        if (
            self.state.view_mode == ViewMode.DETAIL
            and self.state.is_drilled_down()
            and self.state.sub_grouping_mode
        ):
            # Determine field based on sub-grouping mode
            field_map = {
                ViewMode.MERCHANT: "merchant",
                ViewMode.CATEGORY: "category",
                ViewMode.GROUP: "group",
                ViewMode.ACCOUNT: "account",
            }
            group_by_field = field_map[self.state.sub_grouping_mode]

            # Check if multi-select is active
            if len(self.state.selected_group_keys) > 0:
                # Multi-select: get transactions from all selected sub-groups
                transactions_to_toggle = self.controller.get_transactions_from_selected_groups(group_by_field)
                self.state.clear_selection()
            else:
                # Single selection: get transactions from current row
                row_data = self.state.current_data.row(table.cursor_row, named=True)
                group_name = str(row_data.get(self.state.current_data.columns[0]))

                # Get all transactions for this sub-group
                filtered_df = self.state.get_filtered_df()
                if filtered_df is None:
                    return

                if group_by_field == "merchant":
                    transactions_to_toggle = self.data_manager.filter_by_merchant(filtered_df, group_name)
                elif group_by_field == "category":
                    transactions_to_toggle = self.data_manager.filter_by_category(filtered_df, group_name)
                elif group_by_field == "group":
                    transactions_to_toggle = self.data_manager.filter_by_group(filtered_df, group_name)
                elif group_by_field == "account":
                    transactions_to_toggle = self.data_manager.filter_by_account(filtered_df, group_name)

            count = self.controller.queue_hide_toggle_edits(transactions_to_toggle)
            self.notify(
                f"Toggled hide/unhide for {count} transactions. Press w to commit.",
                timeout=3,
            )
            self.refresh_view()
            return

        # Detail view - handle individual or multi-selected transactions
        if self.state.view_mode != ViewMode.DETAIL:
            self.notify("Hide/unhide only works in transaction or aggregate views", timeout=2)
            return

        # Check if multi-select is active
        if len(self.state.selected_ids) > 0:
            # Use controller helper to queue toggle edits for all selected
            selected_txns = self.state.current_data.filter(
                pl.col("id").is_in(list(self.state.selected_ids))
            )
            count = self.controller.queue_hide_toggle_edits(selected_txns)

            self.state.clear_selection()
            self.notify(
                f"Toggled hide/unhide for {count} transactions. Press w to commit.",
                timeout=3,
            )
            self.refresh_view()
        else:
            # Toggle single transaction
            row_data = self.state.current_data.row(table.cursor_row, named=True)
            txn_id = row_data["id"]

            # Check if there's already a pending hide toggle for this transaction
            existing_edit = None
            for edit in self.data_manager.pending_edits:
                if edit.transaction_id == txn_id and edit.field == "hide_from_reports":
                    existing_edit = edit
                    break

            # Save cursor position before refresh
            saved_cursor_row = table.cursor_row

            if existing_edit:
                # Remove the pending edit (undo the toggle)
                self.data_manager.pending_edits.remove(existing_edit)
                self.notify("Reverted hide/unhide change", timeout=2)
            else:
                # Queue a new toggle edit
                current_hidden = row_data.get("hideFromReports", False)
                single_txn = self.state.current_data.filter(pl.col("id") == txn_id)
                self.controller.queue_hide_toggle_edits(single_txn)

                action = "Unhidden" if current_hidden else "Hidden"
                self.notify(f"{action} from reports. Press w to commit.", timeout=2)

            self.refresh_view()
            # Restore cursor position
            if saved_cursor_row < table.row_count:
                table.move_cursor(row=saved_cursor_row)

    def action_show_transaction_details(self) -> None:
        """Show detailed information about current transaction."""
        if self.data_manager is None or self.state.view_mode != ViewMode.DETAIL:
            self.notify("Details only available in transaction view", timeout=2)
            return

        if self.state.current_data is None:
            return

        table = self.query_one("#data-table", DataTable)
        if table.cursor_row < 0:
            return

        # Get current transaction data
        row_data = self.state.current_data.row(table.cursor_row, named=True)

        # Show detail modal (doesn't change view state, just displays info)
        self.push_screen(TransactionDetailScreen(dict(row_data)))

    def action_delete_transaction(self) -> None:
        """Delete current transaction with confirmation."""
        if self.data_manager is None or self.state.view_mode != ViewMode.DETAIL:
            self.notify("Delete only works in transaction detail view", timeout=2)
            return

        self.run_worker(self._delete_transaction(), exclusive=False)

    async def _delete_transaction(self) -> None:
        """Show delete confirmation and delete if confirmed."""
        if self.state.current_data is None:
            return

        table = self.query_one("#data-table", DataTable)
        if table.cursor_row < 0:
            return

        # Get current transaction
        row_data = self.state.current_data.row(table.cursor_row, named=True)
        txn_id = row_data["id"]

        # Show confirmation
        confirmed = await self.push_screen(
            DeleteConfirmationScreen(transaction_count=1), wait_for_dismiss=True
        )

        if confirmed:
            try:
                # Delete via API
                await self.backend.delete_transaction(txn_id)
                self.notify("Transaction deleted", severity="information", timeout=2)

                # Refresh data - need to re-fetch
                # For now, just notify user to refresh
                self.notify("Press Ctrl+L to refresh data from backend", timeout=3)
            except Exception as e:
                self.notify(f"Error deleting: {e}", severity="error", timeout=5)

    def action_go_back(self) -> None:
        """
        Go back to previous view and restore cursor and scroll position.

        To clear search: Press / then Enter with empty search box.
        """
        success, cursor_position, scroll_y = self.state.go_back()
        if success:
            self.refresh_view()
            # Restore cursor and scroll position
            saved_position = {"cursor_row": cursor_position, "scroll_y": scroll_y}
            self._restore_table_position(saved_position)

    async def _do_fresh_login(self, creds):
        """
        Delete stale session and perform fresh login.

        This is the common pattern used in 3 places (login, fetch, commit).
        Extracted to eliminate duplication while preserving the exact logic
        that evolved through multiple bug fixes.

        Args:
            creds: Credentials dict with email, password, mfa_secret

        Raises:
            Exception: If login fails
        """

        logger = get_logger(__name__)

        logger.info("Deleting stale session and performing fresh login")
        self.backend.delete_session()
        self.backend.clear_auth()  # Clear in-memory token/headers

        await self.backend.login(
            email=creds["email"],
            password=creds["password"],
            use_saved_session=False,  # Force fresh login
            save_session=True,
            mfa_secret_key=creds["mfa_secret"],
        )
        logger.info("Fresh login succeeded")

    async def _refresh_session(self) -> bool:
        """Refresh expired session by re-authenticating with stored credentials."""

        logger = get_logger(__name__)

        if self.stored_credentials is None:
            logger.error("Cannot refresh session - no stored credentials")
            return False

        try:
            logger.info("Session expired - attempting to refresh")
            self._notify(NotificationHelper.session_refreshing())
            # Use centralized fresh login logic
            await self._do_fresh_login(self.stored_credentials)
            logger.info("Session refresh succeeded")
            self._notify(NotificationHelper.session_refresh_success())
            return True
        except Exception as e:
            logger.error(f"Session refresh failed: {e}", exc_info=True)
            self._notify(NotificationHelper.session_refresh_failed(str(e)))
            return False

    async def _commit_with_retry(self, edits):
        """
        Commit edits with automatic retry on session expiration.

        Uses exponential backoff (60s, 120s, 240s, 480s, 960s) for transient failures.
        User can press Ctrl-C to abort during retry waits.

        **User Experience:**
        - On auth error: "Session expired, re-authenticating..." → immediate retry
        - On other error: "Commit failed due to {reason}. Retrying in Xs (attempt N/5). Press Ctrl-C to abort."
        - On retry success: Returns normally (no extra notification)
        - On all retries exhausted: Re-raises exception (caller shows error)
        - On user cancel: "Commit cancelled by user"
        """

        logger = get_logger(__name__)

        def on_retry_notification(attempt: int, wait_seconds: float) -> None:
            """
            Show retry progress to user.

            Called AFTER the first failure and BEFORE waiting to retry.
            """
            self._notify(NotificationHelper.retry_waiting(attempt, wait_seconds))

        async def commit_operation():
            """Wrapper to commit and re-authenticate if needed."""
            try:
                return await self.data_manager.commit_pending_edits(edits)
            except Exception as e:
                # Check if it's an auth error (session expired)
                error_msg = str(e).lower()
                if "401" in error_msg or "unauthorized" in error_msg or "token" in error_msg:
                    logger.debug("Commit failed with auth error, attempting session refresh")
                    # Show clear message to user
                    self._notify(NotificationHelper.session_expired())
                    # Try to refresh session once
                    if await self._refresh_session():
                        logger.debug("Session refreshed, retrying commit immediately")
                        # Session refreshed - try commit again immediately
                        return await self.data_manager.commit_pending_edits(edits)
                    else:
                        logger.error("Session refresh failed")
                        # Session refresh failed - will trigger retry with backoff
                        raise Exception("Session refresh failed - will retry with backoff")
                # Re-raise for retry logic to handle
                logger.warning(f"Commit failed: {e}")
                raise

        try:
            # Use retry_with_backoff for robust error handling
            return await retry_with_backoff(
                operation=commit_operation,
                operation_name="Commit changes",
                max_retries=5,
                initial_wait=60.0,
                on_retry=on_retry_notification,
            )
        except RetryAborted:
            # User pressed Ctrl-C
            logger.debug("Commit retry cancelled by user")
            self._notify(NotificationHelper.retry_cancelled())
            raise
        except Exception as e:
            # All retries exhausted
            logger.error(f"All commit retries exhausted: {e}")
            raise

    def action_review_and_commit(self) -> None:
        """Review pending changes and commit if confirmed."""
        if self.data_manager is None:
            return

        count = self.data_manager.get_stats()["pending_changes"]
        if count == 0:
            self._notify(NotificationHelper.no_pending_changes())
            return

        # Show review screen
        self.run_worker(self._review_and_commit(), exclusive=False)

    async def _review_and_commit(self) -> None:
        """Show review screen and commit if confirmed."""
        logger = get_logger(__name__)

        # Save view state AND table position before showing review screen
        saved_state = self.state.save_view_state()
        saved_table_position = self._save_table_position()
        logger.debug(
            f"Saved view state: view_mode={saved_state['view_mode']}, selected_category={saved_state.get('selected_category')}"
        )
        logger.debug(
            f"Saved table position: cursor_row={saved_table_position['cursor_row']}, scroll_y={saved_table_position['scroll_y']}"
        )

        # Show review screen with category names for readable display
        should_commit = await self.push_screen(
            ReviewChangesScreen(self.data_manager.pending_edits, self.data_manager.categories),
            wait_for_dismiss=True,
        )

        if should_commit:
            # Restore view IMMEDIATELY after review screen dismisses to avoid flash
            # User should see their original view while commits are happening
            logger.debug(f"Before restore: view_mode={self.state.view_mode}")
            self.state.restore_view_state(saved_state)
            logger.debug(
                f"After restore: view_mode={self.state.view_mode}, selected_category={self.state.selected_category}"
            )
            self.refresh_view(force_rebuild=False)
            # Restore table position after refresh
            self._restore_table_position(saved_table_position)

            count = len(self.data_manager.pending_edits)
            self._notify(NotificationHelper.commit_starting(count))

            try:
                success_count, failure_count = await self._commit_with_retry(  # type: ignore
                    self.data_manager.pending_edits
                )

                # Show notification based on results
                if failure_count > 0:
                    self._notify(NotificationHelper.commit_partial(success_count, failure_count))
                else:
                    self._notify(NotificationHelper.commit_success(success_count))

                # Delegate to controller for data integrity logic
                # Controller handles: apply edits if success, keep current view if failure
                cache_filters = (
                    {"year": self.cache_year_filter, "since": self.cache_since_filter}
                    if self.cache_manager
                    else None
                )

                self.controller.handle_commit_result(
                    success_count=success_count,
                    failure_count=failure_count,
                    edits=self.data_manager.pending_edits,
                    saved_state=saved_state,
                    cache_filters=cache_filters,
                )
                # Restore table position after commit completes
                self._restore_table_position(saved_table_position)
            except Exception as e:
                self._notify(NotificationHelper.commit_error(str(e)))
                # View already restored above, just refresh to show current state
                self.refresh_view(force_rebuild=False)
                # Restore table position after error refresh
                self._restore_table_position(saved_table_position)
        else:
            # User pressed Escape - restore view state and refresh to go back to where they were
            self.state.restore_view_state(saved_state)
            self.refresh_view(force_rebuild=False)
            # Restore table position after cancel
            self._restore_table_position(saved_table_position)

    def action_quit_app(self) -> None:
        """Quit the application - show confirmation first."""
        # If we're in an error state (no data_manager), just exit immediately
        if self.data_manager is None:
            self.exit()
            return
        # Show confirmation in a worker (required for push_screen with wait_for_dismiss)
        self.run_worker(self._confirm_and_quit(), exclusive=False)

    async def _confirm_and_quit(self) -> None:
        """Show quit confirmation dialog and exit if confirmed."""
        has_changes = (
            (self.data_manager and self.data_manager.get_stats()["pending_changes"] > 0)
            if self.data_manager
            else False
        )

        should_quit = await self.push_screen(
            QuitConfirmationScreen(has_unsaved_changes=has_changes), wait_for_dismiss=True
        )

        if should_quit:
            self.exit()

    async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
        """Handle row selection (Enter key)."""
        table = self.query_one("#data-table", DataTable)
        row_key = event.row_key
        row = table.get_row(row_key)
        item_name = str(row[0])

        # Check if we're in a sub-grouped view (drilled down with sub-grouping)
        if self.state.is_drilled_down() and self.state.sub_grouping_mode:
            # Drilling down from sub-grouped view
            # Set the appropriate selection based on sub_grouping_mode
            if self.state.sub_grouping_mode == ViewMode.CATEGORY:
                self.state.selected_category = item_name
            elif self.state.sub_grouping_mode == ViewMode.GROUP:
                self.state.selected_group = item_name
            elif self.state.sub_grouping_mode == ViewMode.ACCOUNT:
                self.state.selected_account = item_name
            elif self.state.sub_grouping_mode == ViewMode.MERCHANT:
                # This shouldn't happen (can't sub-group by same field), but handle it
                self.state.selected_merchant = item_name

            # Clear sub-grouping mode (show detail view at this level)
            self.state.sub_grouping_mode = None
            self.refresh_view()

        elif self.state.view_mode in [
            ViewMode.MERCHANT,
            ViewMode.CATEGORY,
            ViewMode.GROUP,
            ViewMode.ACCOUNT,
        ]:
            # Drill down from top-level view - save cursor and scroll position for restoration on go_back
            cursor_position = table.cursor_row
            scroll_y = table.scroll_y
            self.state.drill_down(item_name, cursor_position, scroll_y)
            self.refresh_view()


def main():
    """Entry point for the TUI."""
    parser = argparse.ArgumentParser(
        description="moneyflow - Terminal UI for personal finance management"
    )
    parser.add_argument(
        "--year",
        type=int,
        metavar="YYYY",
        help="Only load transactions from this year onwards (e.g., --year 2025 loads from 2025-01-01 to now). Default: load all transactions.",
    )
    parser.add_argument(
        "--since",
        type=str,
        metavar="YYYY-MM-DD",
        help="Only load transactions from this date onwards (e.g., --since 2024-06-01). Overrides --year if both provided.",
    )
    parser.add_argument(
        "--mtd",
        action="store_true",
        help="Load month-to-date transactions (from 1st of current month to today). Fast startup for editing recent transactions. Overrides --year and --since.",
    )
    parser.add_argument(
        "--cache",
        type=str,
        nargs="?",
        const="",  # Use default location if flag given without path
        metavar="PATH",
        help="Enable caching. Optionally specify cache directory (default: ~/.moneyflow/cache/). Without this flag, always fetches fresh data.",
    )
    parser.add_argument(
        "--refresh",
        action="store_true",
        help="Force refresh from API, skip cache even if valid cache exists",
    )
    parser.add_argument(
        "--demo",
        action="store_true",
        help="Run in demo mode with sample data (no authentication required)",
    )

    args = parser.parse_args()

    # Initialize logging (file only - Textual swallows console output anyway)
    logger = setup_logging(console_output=False)
    logger.info("Starting moneyflow application")

    # Determine start year or date range
    start_year = None
    custom_start_date = None

    if args.mtd:
        # Month-to-date: Load from 1st of current month to today

        today = date_type.today()
        first_of_month = date_type(today.year, today.month, 1)
        custom_start_date = first_of_month.strftime("%Y-%m-%d")
    elif args.since:
        custom_start_date = args.since
    elif args.year:
        start_year = args.year

    # Handle cache path
    # If --cache passed without path, use empty string (triggers default in CacheManager)
    # If --cache not passed at all, args.cache is None (no caching)
    cache_path = args.cache if hasattr(args, "cache") and args.cache is not None else None

    try:
        app = MoneyflowApp(
            start_year=start_year,
            custom_start_date=custom_start_date,
            demo_mode=args.demo,
            cache_path=cache_path,
            force_refresh=args.refresh,
        )

        app.run()
    except Exception:
        # Print full traceback to console
        print("\n" + "=" * 80, file=sys.stderr)
        print("FATAL ERROR - moneyflow TUI crashed!", file=sys.stderr)
        print("=" * 80, file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        print("\n" + "=" * 80, file=sys.stderr)
        print("Please report this error with the traceback above.", file=sys.stderr)
        print("=" * 80 + "\n", file=sys.stderr)
        sys.exit(1)


def launch_monarch_mode(
    year: Optional[int] = None,
    since: Optional[str] = None,
    mtd: bool = False,
    cache: Optional[str] = None,
    refresh: bool = False,
    demo: bool = False,
) -> None:
    """
    Launch moneyflow in Monarch Money mode.

    Args:
        year: Only load transactions from this year onwards
        since: Only load transactions from this date onwards (overrides year)
        mtd: Load month-to-date transactions only
        cache: Cache directory path (enables caching if provided)
        refresh: Force refresh from API, skip cache
        demo: Run in demo mode with sample data
    """
    from datetime import date as date_type

    # Initialize logging
    logger = setup_logging(console_output=False)
    logger.info("Starting moneyflow in Monarch mode")

    # Determine start year or date range
    start_year = None
    custom_start_date = None

    if mtd:
        today = date_type.today()
        first_of_month = date_type(today.year, today.month, 1)
        custom_start_date = first_of_month.strftime("%Y-%m-%d")
    elif since:
        custom_start_date = since
    elif year:
        start_year = year

    try:
        app = MoneyflowApp(
            start_year=start_year,
            custom_start_date=custom_start_date,
            demo_mode=demo,
            cache_path=cache,
            force_refresh=refresh,
        )
        app.run()
    except Exception:
        print("\n" + "=" * 80, file=sys.stderr)
        print("FATAL ERROR - moneyflow TUI crashed!", file=sys.stderr)
        print("=" * 80, file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        print("\n" + "=" * 80, file=sys.stderr)
        print("Please report this error with the traceback above.", file=sys.stderr)
        print("=" * 80 + "\n", file=sys.stderr)
        sys.exit(1)


def launch_amazon_mode(db_path: Optional[str] = None) -> None:
    """
    Launch moneyflow in Amazon purchase analysis mode.

    Args:
        db_path: Path to Amazon SQLite database (default: ~/.moneyflow/amazon.db)

    Uses the AmazonBackend with data stored in SQLite.
    Data must be imported first using: moneyflow amazon import <csv>
    """
    from moneyflow.backend_config import BackendConfig
    from moneyflow.backends.amazon import AmazonBackend

    # Initialize logging
    logger = setup_logging(console_output=False)
    logger.info("Starting moneyflow in Amazon mode")

    try:
        # Create Amazon backend and config
        backend = AmazonBackend(db_path=db_path)
        config = BackendConfig.for_amazon()

        # Create MoneyflowApp in Amazon mode
        app = MoneyflowApp(
            demo_mode=False,
            backend=backend,
            config=config,
        )
        app.title = "moneyflow [Amazon]"

        app.run()
    except Exception:
        print("\n" + "=" * 80, file=sys.stderr)
        print("FATAL ERROR - moneyflow Amazon mode crashed!", file=sys.stderr)
        print("=" * 80, file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        print("\n" + "=" * 80, file=sys.stderr)
        print("Please report this error with the traceback above.", file=sys.stderr)
        print("=" * 80 + "\n", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
