"""
SambaPOS MSSQL Database Client

Connects to SambaPOS EPOS database to fetch menu categories and top sellers data.
"""
import logging
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional
import aioodbc

logger = logging.getLogger(__name__)


class SambaPOSClient:
    """Client for querying SambaPOS MSSQL database."""

    def __init__(self, host: str, port: int, database: str, username: str, password: str):
        """
        Initialize SambaPOS client with connection parameters.

        Args:
            host: SQL Server host (e.g., 'localhost\\SQLEXPRESS17')
            port: SQL Server port (default 1433)
            database: Database name (e.g., 'clean')
            username: SQL Server username
            password: SQL Server password
        """
        self.host = host
        self.port = port
        self.database = database
        self.username = username
        self.password = password

        # Build connection string
        # Note: For named instances like 'localhost\SQLEXPRESS17', the port is ignored
        # and the instance name is used for connection
        if '\\' in host:
            # Named instance - don't use port
            self.connection_string = (
                f"DRIVER={{ODBC Driver 17 for SQL Server}};"
                f"SERVER={host};"
                f"DATABASE={database};"
                f"UID={username};"
                f"PWD={password};"
                f"TrustServerCertificate=yes;"
                f"Connection Timeout=30"
            )
        else:
            # Default instance - use port
            self.connection_string = (
                f"DRIVER={{ODBC Driver 17 for SQL Server}};"
                f"SERVER={host},{port};"
                f"DATABASE={database};"
                f"UID={username};"
                f"PWD={password};"
                f"TrustServerCertificate=yes;"
                f"Connection Timeout=30"
            )

        # Log connection details (without password)
        logger.info(f"SambaPOS connection configured: host={host}, database={database}, user={username}")

    async def test_connection(self) -> dict:
        """
        Test database connectivity.

        Returns:
            dict with 'success' bool and 'message' str
        """
        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    await cursor.execute("SELECT 1")
                    await cursor.fetchone()
            return {"success": True, "message": "Connection successful"}
        except Exception as e:
            logger.error(f"SambaPOS connection test failed: {e}")
            return {"success": False, "message": str(e)}

    async def get_categories(self) -> list[dict]:
        """
        Fetch all unique values from the 'Kitchen Course' product tag.

        Returns:
            List of dicts with 'id' and 'name' for each Kitchen Course value
        """
        # Query to get unique Kitchen Course tag values from MenuItems.CustomTags JSON
        # CustomTags format: [{"TN":"Kitchen Course","TV":"Starters"},{"TN":"Kitchen Print","TV":"Y"}]
        # Using CROSS APPLY to safely calculate positions before SUBSTRING
        query = """
            SELECT DISTINCT
                ROW_NUMBER() OVER (ORDER BY KitchenCourse) as Id,
                KitchenCourse as Name
            FROM (
                SELECT DISTINCT
                    CASE
                        WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start
                        THEN SUBSTRING(CustomTags, tv.tv_start, pos.tv_end - tv.tv_start)
                        ELSE NULL
                    END as KitchenCourse
                FROM MenuItems
                CROSS APPLY (
                    SELECT CHARINDEX('"TN":"Kitchen Course"', CustomTags) as tn_pos
                ) tn
                CROSS APPLY (
                    SELECT CASE WHEN tn.tn_pos > 0
                                THEN CHARINDEX('"TV":"', CustomTags, tn.tn_pos) + 6
                                ELSE 0 END as tv_start
                ) tv
                CROSS APPLY (
                    SELECT CASE WHEN tv.tv_start > 6
                                THEN CHARINDEX('"', CustomTags, tv.tv_start)
                                ELSE 0 END as tv_end
                ) pos
                WHERE CustomTags LIKE '%"TN":"Kitchen Course"%'
            ) sub
            WHERE KitchenCourse IS NOT NULL AND KitchenCourse != ''
            ORDER BY KitchenCourse
        """
        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    await cursor.execute(query)
                    rows = await cursor.fetchall()
                    result = [{"id": int(row[0]), "name": row[1].strip()} for row in rows]
                    logger.info(f"Found {len(result)} Kitchen Course categories: {[r['name'] for r in result]}")
                    return result
        except Exception as e:
            logger.error(f"Failed to fetch SambaPOS Kitchen Course categories: {e}")
            raise

    async def debug_menu_items(self) -> dict:
        """
        Debug method to explore MenuItems table structure and sample data.
        Returns column names and sample CustomTags values.
        """
        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    # Get column names from MenuItems table
                    await cursor.execute("""
                        SELECT COLUMN_NAME
                        FROM INFORMATION_SCHEMA.COLUMNS
                        WHERE TABLE_NAME = 'MenuItems'
                        ORDER BY ORDINAL_POSITION
                    """)
                    columns = [row[0] for row in await cursor.fetchall()]

                    # Get sample of CustomTags values (if column exists)
                    sample_tags = []
                    if 'CustomTags' in columns:
                        await cursor.execute("""
                            SELECT TOP 20 Name, CustomTags
                            FROM MenuItems
                            WHERE CustomTags IS NOT NULL AND CustomTags != ''
                        """)
                        sample_tags = [{"name": row[0], "custom_tags": row[1]} for row in await cursor.fetchall()]

                    # Also check for any tag-related tables
                    await cursor.execute("""
                        SELECT TABLE_NAME
                        FROM INFORMATION_SCHEMA.TABLES
                        WHERE TABLE_NAME LIKE '%Tag%' OR TABLE_NAME LIKE '%Property%'
                        ORDER BY TABLE_NAME
                    """)
                    tag_tables = [row[0] for row in await cursor.fetchall()]

                    return {
                        "menuitem_columns": columns,
                        "sample_custom_tags": sample_tags,
                        "tag_related_tables": tag_tables
                    }
        except Exception as e:
            logger.error(f"Debug MenuItems failed: {e}")
            raise

    async def debug_table_schema(self) -> dict:
        """
        Debug method to inspect column names in key SambaPOS tables.
        Used to identify correct column names for SQL queries.
        """
        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    result = {}

                    # Check key tables used in restaurant spend query
                    tables = ['Tickets', 'Orders', 'TicketEntities', 'MenuItems']

                    for table in tables:
                        await cursor.execute(f"""
                            SELECT COLUMN_NAME, DATA_TYPE
                            FROM INFORMATION_SCHEMA.COLUMNS
                            WHERE TABLE_NAME = '{table}'
                            ORDER BY ORDINAL_POSITION
                        """)
                        columns = [{"name": row[0], "type": row[1]} for row in await cursor.fetchall()]
                        result[table] = columns

                    return result
        except Exception as e:
            logger.error(f"Debug table schema failed: {e}")
            raise

    async def debug_zero_price_order_states(self, limit: int = 20) -> list[dict]:
        """
        Returns sample OrderStates blobs for Orders where Price = 0.
        Used to discover the JSON structure so we can extract original/package price.
        """
        query = """
            SELECT TOP (?) o.Id, o.MenuItemName, o.Price, o.Quantity, o.OrderStates
            FROM Orders o
            JOIN Tickets t ON o.TicketId = t.Id
            WHERE o.Price = 0
              AND o.OrderStates IS NOT NULL
              AND o.OrderStates != ''
              AND t.IsClosed = 1
            ORDER BY o.Id DESC
        """
        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    await cursor.execute(query, [limit])
                    rows = await cursor.fetchall()
                    return [
                        {
                            "order_id": row[0],
                            "menu_item_name": row[1],
                            "price": float(row[2]) if row[2] is not None else None,
                            "quantity": float(row[3]) if row[3] is not None else None,
                            "order_states": row[4],
                        }
                        for row in rows
                    ]
        except Exception as e:
            logger.error(f"debug_zero_price_order_states failed: {e}")
            raise

    async def get_product_tag_groups(self) -> list[dict]:
        """
        Discover available product tag groups from the CustomTags field.

        Returns:
            List of unique tag group names found in CustomTags
        """
        query = """
            SELECT DISTINCT
                CASE
                    WHEN CHARINDEX(':', value) > 0
                    THEN LTRIM(RTRIM(LEFT(value, CHARINDEX(':', value) - 1)))
                    ELSE NULL
                END as TagGroup
            FROM MenuItems
            CROSS APPLY STRING_SPLIT(CustomTags, ',')
            WHERE CustomTags IS NOT NULL AND CustomTags != ''
            ORDER BY TagGroup
        """
        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    await cursor.execute(query)
                    rows = await cursor.fetchall()
                    return [{"name": row[0]} for row in rows if row[0]]
        except Exception as e:
            logger.error(f"Failed to fetch SambaPOS tag groups: {e}")
            raise

    async def get_top_sellers(
        self,
        from_date: date,
        to_date: date,
        categories: list[str],
        limit: int = 10,
        excluded_categories: list[str] | None = None
    ) -> dict[str, list[dict]]:
        """
        Get top sellers grouped by Kitchen Course tag.

        Args:
            from_date: Start date for the query (inclusive)
            to_date: End date for the query (inclusive)
            categories: List of Kitchen Course values to include (e.g., ['Starters', 'Mains', 'Desserts'])
            limit: Max items per category (default 10)
            excluded_categories: List of ScreenMenuCategory names to exclude from results

        Returns:
            Dict mapping Kitchen Course to list of items with:
            - item_name: str
            - qty: int
            - revenue: Decimal
        """
        if not categories:
            return {}

        # Build placeholders for categories
        cat_placeholders = ','.join(['?' for _ in categories])

        # Build exclusion filter for MenuItems.GroupCode if provided
        exclusion_filter = ""
        exclusion_params = []
        if excluded_categories:
            # Filter out items where GroupCode matches excluded groups
            excl_placeholders = ','.join(['?' for _ in excluded_categories])
            exclusion_filter = f"AND (mi.GroupCode IS NULL OR mi.GroupCode NOT IN ({excl_placeholders}))"
            exclusion_params = excluded_categories

        # Query using Kitchen Course tag from MenuItems.CustomTags JSON
        # CustomTags format: [{"TN":"Kitchen Course","TV":"Starters"},...]
        # Using CROSS APPLY to safely calculate positions before SUBSTRING
        query = f"""
            SELECT
                KitchenCourse as Category,
                MenuItemName,
                SUM(CAST(Quantity AS DECIMAL(18,2))) as TotalQty,
                SUM(Price * Quantity) as TotalRevenue
            FROM (
                SELECT
                    o.MenuItemName,
                    o.Quantity,
                    o.Price,
                    CASE
                        WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start
                        THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start)
                        ELSE NULL
                    END as KitchenCourse
                FROM Orders o
                JOIN Tickets t ON o.TicketId = t.Id
                JOIN MenuItems mi ON o.MenuItemId = mi.Id
                CROSS APPLY (
                    SELECT CHARINDEX('"TN":"Kitchen Course"', mi.CustomTags) as tn_pos
                ) tn
                CROSS APPLY (
                    SELECT CASE WHEN tn.tn_pos > 0
                                THEN CHARINDEX('"TV":"', mi.CustomTags, tn.tn_pos) + 6
                                ELSE 0 END as tv_start
                ) tv
                CROSS APPLY (
                    SELECT CASE WHEN tv.tv_start > 6
                                THEN CHARINDEX('"', mi.CustomTags, tv.tv_start)
                                ELSE 0 END as tv_end
                ) pos
                WHERE t.Date >= CAST(? AS DATE) AND t.Date < DATEADD(day, 1, CAST(? AS DATE))
                  AND t.IsClosed = 1
                  AND mi.CustomTags LIKE '%"TN":"Kitchen Course"%'
                  AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Void"%')
                  AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Canceled"%')
                  {exclusion_filter}
            ) OrdersWithCourse
            WHERE KitchenCourse IN ({cat_placeholders})
            GROUP BY KitchenCourse, MenuItemName
            ORDER BY KitchenCourse, TotalQty DESC
        """

        # Parameters: from_date, to_date, excluded categories, then Kitchen Course categories
        params = [from_date.isoformat(), to_date.isoformat()] + exclusion_params + categories

        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    await cursor.execute(query, params)
                    rows = await cursor.fetchall()

                    # Group results by category
                    results: dict[str, list[dict]] = {cat: [] for cat in categories}

                    for row in rows:
                        category = row[0]
                        if category in results and len(results[category]) < limit:
                            results[category].append({
                                "item_name": row[1],
                                "qty": int(row[2]) if row[2] else 0,
                                "revenue": Decimal(str(row[3])) if row[3] else Decimal("0")
                            })

                    logger.info(f"SambaPOS top sellers: fetched {len(rows)} rows for {len(categories)} Kitchen Course categories")
                    return results

        except Exception as e:
            logger.error(f"Failed to fetch SambaPOS top sellers: {e}")
            raise

    async def get_top_sellers_by_revenue(
        self,
        from_date: date,
        to_date: date,
        categories: list[str],
        limit: int = 10,
        excluded_categories: list[str] | None = None
    ) -> dict[str, list[dict]]:
        """
        Get top sellers grouped by Kitchen Course tag, sorted by revenue.

        Args:
            from_date: Start date for the query (inclusive)
            to_date: End date for the query (inclusive)
            categories: List of Kitchen Course values to include
            limit: Max items per category (default 10)
            excluded_categories: List of MenuItems.GroupCode values to exclude from results

        Returns:
            Dict mapping Kitchen Course to list of items with:
            - item_name: str
            - qty: int
            - revenue: Decimal
        """
        if not categories:
            return {}

        # Build placeholders for categories
        cat_placeholders = ','.join(['?' for _ in categories])

        # Build exclusion filter for MenuItems.GroupCode if provided
        exclusion_filter = ""
        exclusion_params = []
        if excluded_categories:
            excl_placeholders = ','.join(['?' for _ in excluded_categories])
            exclusion_filter = f"AND (mi.GroupCode IS NULL OR mi.GroupCode NOT IN ({excl_placeholders}))"
            exclusion_params = excluded_categories

        # Query using Kitchen Course tag from MenuItems.CustomTags JSON
        # CustomTags format: [{"TN":"Kitchen Course","TV":"Starters"},...]
        # Using CROSS APPLY to safely calculate positions before SUBSTRING
        query = f"""
            SELECT
                KitchenCourse as Category,
                MenuItemName,
                SUM(CAST(Quantity AS DECIMAL(18,2))) as TotalQty,
                SUM(Price * Quantity) as TotalRevenue
            FROM (
                SELECT
                    o.MenuItemName,
                    o.Quantity,
                    o.Price,
                    CASE
                        WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start
                        THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start)
                        ELSE NULL
                    END as KitchenCourse
                FROM Orders o
                JOIN Tickets t ON o.TicketId = t.Id
                JOIN MenuItems mi ON o.MenuItemId = mi.Id
                CROSS APPLY (
                    SELECT CHARINDEX('"TN":"Kitchen Course"', mi.CustomTags) as tn_pos
                ) tn
                CROSS APPLY (
                    SELECT CASE WHEN tn.tn_pos > 0
                                THEN CHARINDEX('"TV":"', mi.CustomTags, tn.tn_pos) + 6
                                ELSE 0 END as tv_start
                ) tv
                CROSS APPLY (
                    SELECT CASE WHEN tv.tv_start > 6
                                THEN CHARINDEX('"', mi.CustomTags, tv.tv_start)
                                ELSE 0 END as tv_end
                ) pos
                WHERE t.Date >= CAST(? AS DATE) AND t.Date < DATEADD(day, 1, CAST(? AS DATE))
                  AND t.IsClosed = 1
                  AND mi.CustomTags LIKE '%"TN":"Kitchen Course"%'
                  AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Void"%')
                  AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Canceled"%')
                  {exclusion_filter}
            ) OrdersWithCourse
            WHERE KitchenCourse IN ({cat_placeholders})
            GROUP BY KitchenCourse, MenuItemName
            ORDER BY KitchenCourse, TotalRevenue DESC
        """

        # Parameters: from_date, to_date, excluded categories, then Kitchen Course categories
        params = [from_date.isoformat(), to_date.isoformat()] + exclusion_params + categories

        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    await cursor.execute(query, params)
                    rows = await cursor.fetchall()

                    # Group results by category
                    results: dict[str, list[dict]] = {cat: [] for cat in categories}

                    for row in rows:
                        category = row[0]
                        if category in results and len(results[category]) < limit:
                            results[category].append({
                                "item_name": row[1],
                                "qty": int(row[2]) if row[2] else 0,
                                "revenue": Decimal(str(row[3])) if row[3] else Decimal("0")
                            })

                    return results

        except Exception as e:
            logger.error(f"Failed to fetch SambaPOS top sellers by revenue: {e}")
            raise

    async def get_menu_items_with_portions(
        self,
        categories: list[str] | None = None
    ) -> list[dict]:
        """
        Fetch all menu items with their portion names, filtered by Kitchen Course categories.

        Joins MenuItems → MenuItemPortions to get portion-level detail.
        Used for the SambaPOS item picker in the DishEditor.

        Args:
            categories: Optional list of Kitchen Course values to filter by

        Returns:
            List of dicts with menu_item_name, portion_name, category
        """
        # Build optional category filter
        cat_filter = ""
        params = []
        if categories:
            cat_placeholders = ','.join(['?' for _ in categories])
            cat_filter = f"WHERE KitchenCourse IN ({cat_placeholders})"
            params = categories

        query = f"""
            SELECT
                MenuItemName,
                PortionName,
                KitchenCourse,
                MenuItemId
            FROM (
                SELECT
                    mi.Name AS MenuItemName,
                    mip.Name AS PortionName,
                    mi.Id AS MenuItemId,
                    CASE
                        WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start
                        THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start)
                        ELSE NULL
                    END as KitchenCourse
                FROM MenuItems mi
                JOIN MenuItemPortions mip ON mip.MenuItemId = mi.Id
                CROSS APPLY (
                    SELECT CHARINDEX('"TN":"Kitchen Course"', mi.CustomTags) as tn_pos
                ) tn
                CROSS APPLY (
                    SELECT CASE WHEN tn.tn_pos > 0
                                THEN CHARINDEX('"TV":"', mi.CustomTags, tn.tn_pos) + 6
                                ELSE 0 END as tv_start
                ) tv
                CROSS APPLY (
                    SELECT CASE WHEN tv.tv_start > 6
                                THEN CHARINDEX('"', mi.CustomTags, tv.tv_start)
                                ELSE 0 END as tv_end
                ) pos
                WHERE mi.CustomTags LIKE '%"TN":"Kitchen Course"%'
            ) sub
            {cat_filter}
            ORDER BY KitchenCourse, MenuItemName, PortionName
        """

        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    # Get menu item IDs that appear on a POS screen menu
                    pos_menu_item_ids: set[int] = set()
                    try:
                        await cursor.execute("""
                            SELECT DISTINCT smi.MenuItemId
                            FROM ScreenMenuItems smi
                            JOIN ScreenMenuCategories smc ON smc.Id = smi.ScreenMenuCategoryId
                            JOIN ScreenMenus sm ON sm.Id = smc.ScreenMenuId
                        """)
                        pos_menu_item_ids = {row[0] for row in await cursor.fetchall()}
                        logger.info(f"SambaPOS screen menu items: {len(pos_menu_item_ids)} mapped to POS")
                    except Exception as e:
                        logger.warning(f"Could not query ScreenMenuItems (table may not exist): {e}")

                    await cursor.execute(query, params)
                    rows = await cursor.fetchall()

                    results = []
                    for row in rows:
                        if row[0] and row[2]:  # must have name and category
                            results.append({
                                "menu_item_name": row[0],
                                "portion_name": row[1] or "Normal",
                                "category": row[2],
                                "on_pos_menu": row[3] in pos_menu_item_ids
                            })

                    logger.info(f"SambaPOS menu items with portions: {len(results)} items")
                    return results

        except Exception as e:
            logger.error(f"Failed to fetch SambaPOS menu items with portions: {e}")
            raise

    async def get_sales_breakdown(
        self,
        from_date: date,
        to_date: date,
        categories: list[str],
        excluded_categories: list[str] | None = None
    ) -> list[dict]:
        """
        Get all sold items for a date range, grouped by MenuItemName + PortionName + Kitchen Course.

        Used for the Sales GP report — returns all items (no limit) so every sold dish
        can be matched against recipe costs.

        Args:
            from_date: Start date (inclusive)
            to_date: End date (inclusive)
            categories: Kitchen Course values to include
            excluded_categories: GroupCode values to exclude

        Returns:
            List of dicts with menu_item_name, portion_name, category, total_qty, total_revenue_gross
        """
        if not categories:
            return []

        cat_placeholders = ','.join(['?' for _ in categories])

        exclusion_filter = ""
        exclusion_params = []
        if excluded_categories:
            excl_placeholders = ','.join(['?' for _ in excluded_categories])
            exclusion_filter = f"AND (mi.GroupCode IS NULL OR mi.GroupCode NOT IN ({excl_placeholders}))"
            exclusion_params = excluded_categories

        # For DBB/package orders, o.Price is zeroed but the original price is stored in
        # OrderStates as SN="Origional Price" (intentional typo in SambaPOS), SV=<price>.
        # A Package state (SN="Package", SV="DBB") confirms it was a DBB allocation.
        # For orders not yet allocated (price=0, no state yet) we fall back to the
        # catalogue price from MenuItemPrices (PriceTag='').
        query = f"""
            SELECT
                KitchenCourse as Category,
                MenuItemName,
                PortionName,
                SUM(CAST(Quantity AS DECIMAL(18,2))) as TotalQty,
                SUM(EffectivePrice * Quantity) as TotalRevenue,
                SUM(CASE WHEN IsPackage = 1
                         THEN CAST(Quantity AS DECIMAL(18,2))
                         ELSE 0 END) as PackageQty
            FROM (
                SELECT
                    o.MenuItemName,
                    o.PortionName,
                    o.Quantity,
                    o.Price,
                    -- Original price from DBB allocation state (SN='Origional Price', value in SV)
                    orig.OriginalPrice,
                    -- Catalogue price fallback from MenuItemPrices (for not-yet-allocated orders)
                    mip.Price as CataloguePrice,
                    -- 1 = confirmed DBB package order
                    CASE WHEN orig.OriginalPrice IS NOT NULL THEN 1 ELSE 0 END as IsPackage,
                    -- Effective price: original > catalogue > actual (0)
                    CASE
                        WHEN o.Price = 0 AND orig.OriginalPrice > 0
                            THEN orig.OriginalPrice
                        WHEN o.Price = 0 AND ISNULL(mip.Price, 0) > 0
                            THEN mip.Price
                        ELSE o.Price
                    END as EffectivePrice,
                    CASE
                        WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start
                        THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start)
                        ELSE NULL
                    END as KitchenCourse
                FROM Orders o
                JOIN Tickets t ON o.TicketId = t.Id
                JOIN MenuItems mi ON o.MenuItemId = mi.Id
                -- Extract original price from OrderStates using CHARINDEX/SUBSTRING.
                -- SambaPOS stores it as: {{"SN":"Origional Price","SV":"9.75",...}}
                -- Compatible with all SQL Server versions (no OPENJSON needed).
                OUTER APPLY (
                    SELECT
                        CASE
                            WHEN sn_pos.p > 0
                             AND sv_pos.p > 0
                             AND CHARINDEX('"', o.OrderStates, sv_pos.p + 6) > sv_pos.p + 6
                             AND ISNUMERIC(SUBSTRING(
                                     o.OrderStates,
                                     sv_pos.p + 6,
                                     CHARINDEX('"', o.OrderStates, sv_pos.p + 6) - sv_pos.p - 6
                                 )) = 1
                            THEN CAST(SUBSTRING(
                                     o.OrderStates,
                                     sv_pos.p + 6,
                                     CHARINDEX('"', o.OrderStates, sv_pos.p + 6) - sv_pos.p - 6
                                 ) AS DECIMAL(18,2))
                            ELSE NULL
                        END AS OriginalPrice
                    FROM (
                        SELECT CHARINDEX('"SN":"Origional Price"', o.OrderStates) AS p
                    ) sn_pos
                    CROSS APPLY (
                        SELECT CHARINDEX('"SV":"', o.OrderStates, sn_pos.p) AS p
                    ) sv_pos
                ) orig
                -- Catalogue price fallback for not-yet-allocated orders
                LEFT JOIN MenuItemPortions mipn
                    ON mipn.MenuItemId = mi.Id AND mipn.Name = o.PortionName
                LEFT JOIN MenuItemPrices mip
                    ON mip.MenuItemPortionId = mipn.Id
                    AND (mip.PriceTag IS NULL OR mip.PriceTag = '')
                CROSS APPLY (
                    SELECT CHARINDEX('"TN":"Kitchen Course"', mi.CustomTags) as tn_pos
                ) tn
                CROSS APPLY (
                    SELECT CASE WHEN tn.tn_pos > 0
                                THEN CHARINDEX('"TV":"', mi.CustomTags, tn.tn_pos) + 6
                                ELSE 0 END as tv_start
                ) tv
                CROSS APPLY (
                    SELECT CASE WHEN tv.tv_start > 6
                                THEN CHARINDEX('"', mi.CustomTags, tv.tv_start)
                                ELSE 0 END as tv_end
                ) pos
                WHERE t.Date >= CAST(? AS DATE) AND t.Date < DATEADD(day, 1, CAST(? AS DATE))
                  AND t.IsClosed = 1
                  AND mi.CustomTags LIKE '%"TN":"Kitchen Course"%'
                  AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"SN":"Void"%')
                  AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"SN":"Canceled"%')
                  {exclusion_filter}
            ) OrdersWithCourse
            WHERE KitchenCourse IN ({cat_placeholders})
            GROUP BY KitchenCourse, MenuItemName, PortionName
            ORDER BY KitchenCourse, TotalRevenue DESC
        """

        params = [from_date.isoformat(), to_date.isoformat()] + exclusion_params + categories

        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    await cursor.execute(query, params)
                    rows = await cursor.fetchall()

                    results = []
                    for row in rows:
                        results.append({
                            "category": row[0],
                            "menu_item_name": row[1],
                            "portion_name": row[2] or "Normal",
                            "total_qty": int(row[3]) if row[3] else 0,
                            "total_revenue_gross": Decimal(str(row[4])) if row[4] else Decimal("0"),
                            "package_qty": int(row[5]) if row[5] else 0,
                        })

                    logger.info(f"SambaPOS sales breakdown: {len(results)} item/portion combinations for {from_date} to {to_date}")
                    return results

        except Exception as e:
            logger.error(f"Failed to fetch SambaPOS sales breakdown: {e}")
            raise

    async def get_menu_group_codes(self) -> list[dict]:
        """
        Fetch all distinct GroupCode values from MenuItems table.

        GroupCode is used for categorizing menu items (e.g., 'Drinks', 'Extras').
        Used for excluding entire groups from top sellers reports.

        Returns:
            List of dicts with 'name' for each group code
        """
        query = """
            SELECT DISTINCT GroupCode
            FROM MenuItems
            WHERE GroupCode IS NOT NULL AND GroupCode != ''
            ORDER BY GroupCode
        """
        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    await cursor.execute(query)
                    rows = await cursor.fetchall()
                    result = [{"name": row[0].strip()} for row in rows]
                    logger.info(f"Found {len(result)} MenuItems GroupCodes: {[r['name'] for r in result]}")
                    return result
        except Exception as e:
            logger.error(f"Failed to fetch MenuItems GroupCodes: {e}")
            raise

    async def get_gl_codes(self) -> list[dict]:
        """
        Fetch all unique GL codes from NewBook GLA custom tags.

        NewBook GLA format in CustomTags: [{"TN":"NewBook GLA","TV":"3101"},...]

        Returns:
            List of dicts with 'code' for each unique GL code (sorted)
        """
        query = """
            SELECT DISTINCT
                GLACode
            FROM (
                SELECT
                    CASE
                        WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start
                        THEN SUBSTRING(CustomTags, tv.tv_start, pos.tv_end - tv.tv_start)
                        ELSE NULL
                    END as GLACode
                FROM MenuItems
                CROSS APPLY (
                    SELECT CHARINDEX('"TN":"NewBook GLA"', CustomTags) as gla_pos
                ) gla
                CROSS APPLY (
                    SELECT CASE WHEN gla.gla_pos > 0
                                THEN CHARINDEX('"TV":"', CustomTags, gla.gla_pos) + 6
                                ELSE 0 END as tv_start
                ) tv
                CROSS APPLY (
                    SELECT CASE WHEN tv.tv_start > 6
                                THEN CHARINDEX('"', CustomTags, tv.tv_start)
                                ELSE 0 END as tv_end
                ) pos
                WHERE CustomTags LIKE '%"TN":"NewBook GLA"%'
            ) sub
            WHERE GLACode IS NOT NULL AND GLACode != ''
            ORDER BY GLACode
        """
        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    await cursor.execute(query)
                    rows = await cursor.fetchall()
                    result = [{"code": row[0].strip()} for row in rows if row[0] and row[0].strip()]
                    logger.info(f"Found {len(result)} unique GL codes from ProductTag: {[r['code'] for r in result][:10]}...")
                    return result
        except Exception as e:
            logger.error(f"Failed to fetch GL codes from ProductTag: {e}")
            raise

    async def get_restaurant_spend(
        self,
        from_date: date,
        to_date: date,
        tracked_categories: list[str],
        food_gl_codes: list[str],
        beverage_gl_codes: list[str]
    ) -> list[dict]:
        """
        Get restaurant spend data for Resos booking matching (Phase 8).

        Filters tickets to restaurant tables with tracked course items,
        extracts GL codes from NewBook GLA custom tags, and splits spend
        by food/beverage categories.

        Args:
            from_date: Start date (inclusive)
            to_date: End date (inclusive)
            tracked_categories: Kitchen Course values to include (e.g., ['Starters', 'Mains'])
            food_gl_codes: GL codes for food items (e.g., ['3101'])
            beverage_gl_codes: GL codes for beverage items (e.g., ['2101', '2102'])

        Returns:
            List of ticket data dicts with:
            - ticket_id: int
            - ticket_date: date
            - ticket_time: time
            - table_name: str (for fallback matching)
            - ticket_tag: str | None (for primary matching - "BOOKING_ID - Name")
            - booking_id: str | None (extracted from ticket_tag)
            - food_total: Decimal
            - beverage_total: Decimal
            - total_spend: Decimal
            - service_period: str | None (based on timestamp)
        """
        if not tracked_categories:
            logger.warning("get_restaurant_spend called with empty tracked_categories")
            return []

        # VALIDATE GL CODES CONFIGURED
        if not food_gl_codes or not beverage_gl_codes:
            raise ValueError("Food and Beverage GL codes must be configured in Settings > SambaPOS Integration")

        # Build placeholders for categories
        cat_placeholders = ','.join(['?' for _ in tracked_categories])

        # Build GL code IN clauses
        food_gl_placeholders = ','.join(['?' for _ in food_gl_codes]) if food_gl_codes else "''"
        beverage_gl_placeholders = ','.join(['?' for _ in beverage_gl_codes]) if beverage_gl_codes else "''"

        # Query tickets with table entities and tracked course items
        # Calculate food and beverage spend separately based on GL codes
        query = f"""
            WITH FoodSpend AS (
                SELECT
                    t.Id,
                    SUM(o.Price * o.Quantity) as FoodTotal
                FROM Orders o
                INNER JOIN Tickets t ON o.TicketId = t.Id
                INNER JOIN MenuItems mi ON o.MenuItemId = mi.Id
                CROSS APPLY (
                    SELECT CHARINDEX('"TN":"NewBook GLA"', mi.CustomTags) as gla_pos
                ) gla
                CROSS APPLY (
                    SELECT CASE WHEN gla.gla_pos > 0
                                THEN CHARINDEX('"TV":"', mi.CustomTags, gla.gla_pos) + 6
                                ELSE 0 END as tv_start
                ) tv
                CROSS APPLY (
                    SELECT CASE WHEN tv.tv_start > 6
                                THEN CHARINDEX('"', mi.CustomTags, tv.tv_start)
                                ELSE 0 END as tv_end
                ) pos
                CROSS APPLY (
                    SELECT CASE
                        WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start
                        THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start)
                        ELSE NULL
                    END as GLACode
                ) tag
                WHERE t.Date >= CAST(? AS DATE)
                AND t.Date < DATEADD(day, 1, CAST(? AS DATE))
                AND t.IsClosed = 1
                AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Void"%')
                AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Canceled"%')
                AND tag.GLACode IN ({food_gl_placeholders})
                GROUP BY t.Id
            ),
            BeverageSpend AS (
                SELECT
                    t.Id,
                    SUM(o.Price * o.Quantity) as BeverageTotal
                FROM Orders o
                INNER JOIN Tickets t ON o.TicketId = t.Id
                INNER JOIN MenuItems mi ON o.MenuItemId = mi.Id
                CROSS APPLY (
                    SELECT CHARINDEX('"TN":"NewBook GLA"', mi.CustomTags) as gla_pos
                ) gla
                CROSS APPLY (
                    SELECT CASE WHEN gla.gla_pos > 0
                                THEN CHARINDEX('"TV":"', mi.CustomTags, gla.gla_pos) + 6
                                ELSE 0 END as tv_start
                ) tv
                CROSS APPLY (
                    SELECT CASE WHEN tv.tv_start > 6
                                THEN CHARINDEX('"', mi.CustomTags, tv.tv_start)
                                ELSE 0 END as tv_end
                ) pos
                CROSS APPLY (
                    SELECT CASE
                        WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start
                        THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start)
                        ELSE NULL
                    END as GLACode
                ) tag
                WHERE t.Date >= CAST(? AS DATE)
                AND t.Date < DATEADD(day, 1, CAST(? AS DATE))
                AND t.IsClosed = 1
                AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Void"%')
                AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Canceled"%')
                AND tag.GLACode IN ({beverage_gl_placeholders})
                GROUP BY t.Id
            ),
            MainCourseCount AS (
                SELECT
                    t.Id,
                    COUNT(o.Id) as MainCourseCount
                FROM Orders o
                INNER JOIN Tickets t ON o.TicketId = t.Id
                INNER JOIN MenuItems mi ON o.MenuItemId = mi.Id
                CROSS APPLY (
                    SELECT CHARINDEX('"TN":"Kitchen Course"', mi.CustomTags) as kc_pos
                ) kc
                CROSS APPLY (
                    SELECT CASE WHEN kc.kc_pos > 0
                                THEN CHARINDEX('"TV":"', mi.CustomTags, kc.kc_pos) + 6
                                ELSE 0 END as tv_start
                ) tv
                CROSS APPLY (
                    SELECT CASE WHEN tv.tv_start > 6
                                THEN CHARINDEX('"', mi.CustomTags, tv.tv_start)
                                ELSE 0 END as tv_end
                ) pos
                CROSS APPLY (
                    SELECT CASE
                        WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start
                        THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start)
                        ELSE NULL
                    END as KitchenCourse
                ) course
                WHERE t.Date >= CAST(? AS DATE)
                AND t.Date < DATEADD(day, 1, CAST(? AS DATE))
                AND t.IsClosed = 1
                AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Void"%')
                AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Canceled"%')
                AND course.KitchenCourse IN ({cat_placeholders})
                GROUP BY t.Id
            )
            SELECT DISTINCT
                t.Id as TicketId,
                t.Date as TicketDate,
                t.LastUpdateTime as TicketClosedTime,

                -- Calculate "practical ticket time" as average of food order timestamps
                -- Handles pre-dinner drinks scenario (bar tab before booking time)
                (
                    SELECT AVG(CAST(o.CreatedDateTime AS FLOAT))
                    FROM Orders o
                    INNER JOIN MenuItems mi ON o.MenuItemId = mi.Id
                    CROSS APPLY (
                        SELECT CHARINDEX('"TN":"NewBook GLA"', mi.CustomTags) as gla_pos
                    ) gla
                    CROSS APPLY (
                        SELECT CASE WHEN gla.gla_pos > 0
                                    THEN CHARINDEX('"TV":"', mi.CustomTags, gla.gla_pos) + 6
                                    ELSE 0 END as tv_start
                    ) tv
                    CROSS APPLY (
                        SELECT CASE WHEN tv.tv_start > 6
                                    THEN CHARINDEX('"', mi.CustomTags, tv.tv_start)
                                    ELSE 0 END as tv_end
                    ) pos
                    CROSS APPLY (
                        SELECT CASE
                            WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start
                            THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start)
                            ELSE NULL
                        END as GLACode
                    ) tag
                    WHERE o.TicketId = t.Id
                    AND tag.GLACode IN ({food_gl_placeholders})
                    AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Void"%')
                    AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Canceled"%')
                ) as AvgFoodOrderTime,

                t.TicketTags as TicketTags,

                -- Extract table name from TicketEntities
                (
                    SELECT TOP 1 EntityName
                    FROM TicketEntities te
                    WHERE te.Ticket_Id = t.Id
                    AND te.EntityTypeId = 2
                ) as TableName,

                -- Check if ticket has a Room entity (EntityTypeId = 3)
                -- If this returns a value, the customer selected their room = they're a resident
                (
                    SELECT TOP 1 EntityName
                    FROM TicketEntities te
                    WHERE te.Ticket_Id = t.Id
                    AND te.EntityTypeId = 3
                ) as RoomEntity,

                -- Food and beverage spend from GL codes
                ISNULL(fs.FoodTotal, 0) as FoodTotal,
                ISNULL(bs.BeverageTotal, 0) as BeverageTotal,

                -- Main course count for fallback cover estimation
                ISNULL(mcc.MainCourseCount, 0) as MainCourseCount

            FROM Tickets t
            LEFT JOIN FoodSpend fs ON fs.Id = t.Id
            LEFT JOIN BeverageSpend bs ON bs.Id = t.Id
            LEFT JOIN MainCourseCount mcc ON mcc.Id = t.Id
            WHERE t.Date >= CAST(? AS DATE)
              AND t.Date < DATEADD(day, 1, CAST(? AS DATE))
              AND t.IsClosed = 1
              -- Must have table entity
              AND EXISTS (
                  SELECT 1 FROM TicketEntities te
                  WHERE te.Ticket_Id = t.Id AND te.EntityTypeId = 2
              )
              -- Must have at least one tracked course item
              AND EXISTS (
                  SELECT 1
                  FROM Orders ord
                  JOIN Tickets tck ON ord.TicketId = tck.Id
                  JOIN MenuItems mi2 ON ord.MenuItemId = mi2.Id
                  CROSS APPLY (
                      SELECT CHARINDEX('"TN":"Kitchen Course"', mi2.CustomTags) as kc_pos
                  ) kc
                  CROSS APPLY (
                      SELECT CASE WHEN kc.kc_pos > 0
                                  THEN CHARINDEX('"TV":"', mi2.CustomTags, kc.kc_pos) + 6
                                  ELSE 0 END as tv_start
                  ) tv
                  CROSS APPLY (
                      SELECT CASE WHEN tv.tv_start > 6
                                  THEN CHARINDEX('"', mi2.CustomTags, tv.tv_start)
                                  ELSE 0 END as tv_end
                  ) pos
                  CROSS APPLY (
                      SELECT CASE
                          WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start
                          THEN SUBSTRING(mi2.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start)
                          ELSE NULL
                      END as KitchenCourse
                  ) course
                  WHERE tck.Id = t.Id
                  AND course.KitchenCourse IN ({cat_placeholders})
                  AND (ord.OrderStates IS NULL OR ord.OrderStates NOT LIKE '%"S":"Void"%')
                  AND (ord.OrderStates IS NULL OR ord.OrderStates NOT LIKE '%"S":"Canceled"%')
              )
            ORDER BY t.Date
        """

        # Build params:
        # - FoodSpend CTE: from_date, to_date, food_gl_codes (if any)
        # - BeverageSpend CTE: from_date, to_date, beverage_gl_codes (if any)
        # - MainCourseCount CTE: from_date, to_date, tracked_categories
        # - AvgFoodOrderTime subquery: food_gl_codes (if any)
        # - Main query: from_date, to_date, tracked_categories
        params = [
            from_date.isoformat(),  # FoodSpend CTE from_date
            to_date.isoformat(),    # FoodSpend CTE to_date
        ]
        if food_gl_codes:
            params.extend(food_gl_codes)  # FoodSpend CTE GL codes

        params.extend([
            from_date.isoformat(),  # BeverageSpend CTE from_date
            to_date.isoformat(),    # BeverageSpend CTE to_date
        ])
        if beverage_gl_codes:
            params.extend(beverage_gl_codes)  # BeverageSpend CTE GL codes

        params.extend([
            from_date.isoformat(),  # MainCourseCount CTE from_date
            to_date.isoformat(),    # MainCourseCount CTE to_date
        ])
        params.extend(tracked_categories)  # MainCourseCount CTE Kitchen Course categories

        # AvgFoodOrderTime subquery in SELECT
        if food_gl_codes:
            params.extend(food_gl_codes)  # AvgFoodOrderTime subquery GL codes

        params.extend([
            from_date.isoformat(),  # Main query from_date
            to_date.isoformat()     # Main query to_date
        ])
        params.extend(tracked_categories)  # Main query Kitchen Course categories

        try:
            async with aioodbc.connect(dsn=self.connection_string) as conn:
                async with conn.cursor() as cursor:
                    # Log the query for debugging
                    logger.info(f"Executing restaurant spend query with {len(params)} parameters")
                    logger.info(f"Params: {params}")
                    logger.info(f"Query:\n{query}")
                    await cursor.execute(query, params)
                    rows = await cursor.fetchall()

                    results = []
                    for row in rows:
                        ticket_id = row[0]
                        ticket_date = row[1]
                        closed_time = row[2]  # t.LastUpdateTime (DATETIME)
                        avg_food_time_float = row[3]  # AvgFoodOrderTime (FLOAT or None)
                        ticket_tags = row[4] if row[4] else ""
                        table_name = row[5]
                        room_entity = row[6]  # Room entity name if customer selected their room
                        food_total = Decimal(str(row[7])) if row[7] else Decimal("0")
                        beverage_total = Decimal(str(row[8])) if row[8] else Decimal("0")
                        main_course_count = int(row[9]) if row[9] else 0
                        total_spend = food_total + beverage_total

                        # Calculate practical ticket time from average food order timestamps
                        # Handles pre-dinner drinks scenario (bar tab before booking time)
                        ticket_time = None
                        if avg_food_time_float:
                            # SQL AVG(CAST(datetime AS FLOAT)) returns days since 1900-01-01
                            # Convert back to datetime
                            base_date = datetime(1900, 1, 1)
                            practical_datetime = base_date + timedelta(days=float(avg_food_time_float))
                            ticket_time = practical_datetime.time()
                        elif closed_time:
                            # Fallback to ticket closed time if no food orders
                            ticket_time = closed_time.time() if closed_time else None

                        # Extract booking ID from ticket tags
                        # Tag format: "BOOKING_ID - Guest Name" or similar
                        booking_id = None
                        # Placeholder for future implementation:
                        # if " - " in ticket_tags:
                        #     booking_id = ticket_tags.split(" - ")[0].strip()

                        # Parse "Covers" from ticket tags (JSON array format)
                        # Tags format: [{"TN":"Tag Name","TV":"Tag Value"}, ...]
                        covers = 0
                        if ticket_tags:
                            import json
                            try:
                                tags_list = json.loads(ticket_tags)
                                for tag in tags_list:
                                    if tag.get('TN') == 'Covers':
                                        covers = int(tag.get('TV', 0))
                                        break
                            except (json.JSONDecodeError, ValueError, KeyError):
                                pass

                        # Fallback to main course count if covers tag is 0 or not found
                        if covers == 0 and main_course_count > 0:
                            covers = main_course_count

                        results.append({
                            "ticket_id": ticket_id,
                            "ticket_date": ticket_date,
                            "ticket_time": ticket_time,  # Practical time from avg food orders
                            "ticket_closed_time": closed_time,  # Actual closed time
                            "table_name": table_name,
                            "room_entity": room_entity,
                            "has_room_entity": bool(room_entity),
                            "ticket_tag": ticket_tags if ticket_tags else None,
                            "booking_id": booking_id,
                            "food_total": food_total,
                            "beverage_total": beverage_total,
                            "total_spend": total_spend,
                            "estimated_covers": covers,  # Estimated covers from tags or main course count
                            "main_course_count": main_course_count  # For debugging/validation
                        })

                    logger.info(f"SambaPOS restaurant spend: fetched {len(results)} tickets with table entities")
                    return results

        except Exception as e:
            logger.error(f"Failed to fetch SambaPOS restaurant spend: {e}")
            raise
