# Issue #46: Modularization - Extract maintenance operations from cache.py
# Issue #158: Stathera — delete_bars_by_identity, store_bars_replacing, _wait_for_mutations
"""Table maintenance operations for ClickHouse open deviation bar cache.

Provides mixin methods for deduplication and merge monitoring.
Used by OpenDeviationBarCache via mixin inheritance.
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from opendeviationbar.config.network import NetworkConfig

logger = logging.getLogger(__name__)


def _net_cfg() -> NetworkConfig:
    from opendeviationbar.config.network import NetworkConfig
    return NetworkConfig()


class MaintenanceMixin:
    """Mixin for table maintenance operations (dedup, merge monitoring).

    Requires `self.client` from ClickHouseClientMixin.
    """

    def count_duplicates(
        self,
        symbol: str | None = None,
        threshold_decimal_bps: int | None = None,
    ) -> list[dict]:
        """Count duplicate rows by timestamp (same ORDER BY key).

        ReplacingMergeTree deduplicates during background merges, not at insert
        time. This method identifies rows that will be deduplicated on next merge.

        Parameters
        ----------
        symbol : str | None
            Filter to specific symbol, or None for all symbols
        threshold_decimal_bps : int | None
            Filter to specific threshold, or None for all thresholds

        Returns
        -------
        list[dict]
            List of dicts with keys: symbol, threshold_decimal_bps, close_time_ms,
            duplicate_count, opens, closes. Empty list if no duplicates found.

        Examples
        --------
        >>> with OpenDeviationBarCache() as cache:
        ...     dupes = cache.count_duplicates("BTCUSDT", 1000)
        ...     print(f"Found {len(dupes)} timestamp(s) with duplicates")
        """
        where_clauses = []
        params = {}

        if symbol is not None:
            where_clauses.append("symbol = {symbol:String}")
            params["symbol"] = symbol

        if threshold_decimal_bps is not None:
            where_clauses.append("threshold_decimal_bps = {threshold:UInt32}")
            params["threshold"] = threshold_decimal_bps

        where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""

        # Issue #158 Phase 7: GROUP BY first_agg_trade_id (matches ORDER BY key).
        # Rows with same (symbol, threshold, ouroboros, first_agg_trade_id) are
        # duplicates under the new schema. Fall back to close_time_ms for
        # legacy rows (first_agg_trade_id=0).
        query = f"""
            SELECT
                symbol,
                threshold_decimal_bps,
                first_agg_trade_id,
                count(*) as duplicate_count,
                groupArray(open) as opens,
                groupArray(close) as closes
            FROM opendeviationbar_cache.open_deviation_bars
            {where_sql}
            GROUP BY symbol, threshold_decimal_bps, first_agg_trade_id
            HAVING count(*) > 1
            ORDER BY symbol, threshold_decimal_bps, first_agg_trade_id
        """

        result = self.client.query(query, parameters=params)
        return [
            {
                "symbol": row[0],
                "threshold_decimal_bps": row[1],
                "first_agg_trade_id": row[2],
                "duplicate_count": row[3],
                "opens": row[4],
                "closes": row[5],
            }
            for row in result.result_rows
        ]

    def deduplicate_bars(
        self,
        symbol: str | None = None,
        threshold_decimal_bps: int | None = None,
        timeout: int = 600,
    ) -> None:
        """Force deduplication via OPTIMIZE TABLE FINAL with timeout resilience.

        Issue #90: Rewritten as fire-and-forget pattern. OPTIMIZE commands are
        submitted with short client-side timeouts. If the client times out,
        the merge continues server-side. Progress is monitored via system.merges.

        ReplacingMergeTree only deduplicates during background merges. This
        method forces an immediate merge to remove duplicate rows (same
        symbol + threshold + close_time_ms), keeping the row with the latest
        computed_at timestamp.

        Parameters
        ----------
        symbol : str | None
            Optimize only partitions for this symbol, or None for all
        threshold_decimal_bps : int | None
            Optimize only partitions for this threshold, or None for all
        timeout : int
            Maximum seconds to wait for merges to complete (default: 600).
            After timeout, merges continue in background on the server.

        Examples
        --------
        >>> with OpenDeviationBarCache() as cache:
        ...     # Check for duplicates first
        ...     dupes = cache.count_duplicates("BTCUSDT", 1000)
        ...     if dupes:
        ...         print(f"Found {len(dupes)} duplicates, deduplicating...")
        ...         cache.deduplicate_bars("BTCUSDT", 1000)
        """
        if symbol is not None and threshold_decimal_bps is not None:
            logger.info(
                "Deduplicating bars for %s @ %d dbps (OPTIMIZE FINAL)",
                symbol,
                threshold_decimal_bps,
            )
            # Get list of partitions for this symbol/threshold
            partition_query = """
                SELECT DISTINCT partition
                FROM system.parts
                WHERE database = 'opendeviationbar_cache'
                  AND table = 'open_deviation_bars'
                  AND active = 1
                  AND partition LIKE {pattern:String}
            """
            pattern = f"('{symbol}',{threshold_decimal_bps},%"
            result = self.client.query(partition_query, parameters={"pattern": pattern})

            for row in result.result_rows:
                partition_id = row[0]
                optimize_sql = (
                    f"OPTIMIZE TABLE opendeviationbar_cache.open_deviation_bars "
                    f"PARTITION {partition_id} FINAL"
                )
                try:
                    self.client.command(
                        optimize_sql,
                        settings={
                            "send_timeout": 60,
                            "receive_timeout": 60,
                        },
                    )
                    logger.debug("Optimized partition %s", partition_id)
                except Exception as e:
                    # Client timeout — OPTIMIZE continues server-side
                    # Catches OSError, RuntimeError, and OperationalError
                    # from clickhouse-connect HTTP timeout (read timeout=300s)
                    logger.info(
                        "OPTIMIZE timed out client-side for partition %s, "
                        "merge continues on server: %s",
                        partition_id,
                        e,
                    )
        else:
            # Optimize entire table
            logger.info("Deduplicating all bars (OPTIMIZE TABLE FINAL)")
            try:
                self.client.command(
                    "OPTIMIZE TABLE opendeviationbar_cache.open_deviation_bars FINAL",
                    settings={
                        "send_timeout": 60,
                        "receive_timeout": 60,
                    },
                )
            except Exception as e:
                logger.info(
                    "Full-table OPTIMIZE timed out client-side, "
                    "merge continues on server: %s",
                    e,
                )

        # Poll system.merges until our table's merges complete
        self._wait_for_merges(timeout=timeout)
        logger.info("Deduplication complete")

    def delete_bars_by_identity(
        self,
        symbol: str,
        threshold_decimal_bps: int,
        bars: list[tuple[int, int, int]],
        ouroboros_mode: str | None = None,  # SSoT-OK: resolved from config
        timeout: int = 300,
    ) -> int:
        """Delete specific bars identified by (close_time_ms, first_tid, last_tid).

        Issue #158: Stathera cleanup — delete redundant bars from overlap chains.

        Parameters
        ----------
        symbol : str
            Trading symbol
        threshold_decimal_bps : int
            Threshold in decimal basis points
        bars : list[tuple[int, int, int]]
            List of (close_time_ms, first_agg_trade_id, last_agg_trade_id) tuples
        ouroboros_mode : str | None
            Ouroboros mode. Resolved from config if None.
        timeout : int
            Max seconds to wait for mutation (default: 300).

        Returns
        -------
        int
            Number of bars submitted for deletion.
        """
        if not bars:
            return 0

        if ouroboros_mode is None:
            from opendeviationbar.ouroboros import get_operational_ouroboros_mode

            ouroboros_mode = get_operational_ouroboros_mode()

        in_values = ", ".join(
            f"({ts}, {first}, {last})" for ts, first, last in bars
        )
        delete_sql = (
            "ALTER TABLE opendeviationbar_cache.open_deviation_bars "
            f"DELETE WHERE symbol = '{symbol}' "
            f"AND threshold_decimal_bps = {threshold_decimal_bps} "
            f"AND ouroboros_mode = '{ouroboros_mode}' "
            f"AND (close_time_ms, first_agg_trade_id, last_agg_trade_id) "
            f"IN ({in_values})"
        )
        self.client.command(delete_sql)
        self._wait_for_mutations(timeout=timeout)

        logger.info(
            "Deleted %d bars for %s@%d via Stathera cleanup",
            len(bars),
            symbol,
            threshold_decimal_bps,
        )
        return len(bars)

    def store_bars_replacing(
        self,
        symbol: str,
        threshold_decimal_bps: int,
        bars_df: object,
        ouroboros_mode: str | None = None,  # SSoT-OK: resolved from config
        version: str | None = None,
        _skip_lock: bool = False,
    ) -> int:
        """Idempotent replace: delete overlapping bars then insert new ones.

        Issue #158: Stathera — used by kintsugi repairs where the writer is
        authoritative. Deletes bars in the trade-ID range, waits for mutation,
        then inserts new bars.

        Issue #158 Phase 7: Wraps the full DELETE+INSERT in a Redis write lock
        to prevent concurrent writers from interleaving. The inner
        store_bars_batch call uses _skip_lock=True to avoid deadlock.

        Parameters
        ----------
        symbol : str
            Trading symbol
        threshold_decimal_bps : int
            Threshold in decimal basis points
        bars_df : polars.DataFrame
            New bars to insert (must have first_agg_trade_id, last_agg_trade_id)
        ouroboros_mode : str | None
            Ouroboros mode. Resolved from config if None.
        version : str | None
            Version string for source tagging.
        _skip_lock : bool
            If True, skip Redis lock acquisition. Used when caller already
            holds cache_write_lock (e.g. kintsugi _repair_under_lock).

        Returns
        -------
        int
            Number of rows inserted.
        """
        import polars as pl

        if ouroboros_mode is None:
            from opendeviationbar.ouroboros import get_operational_ouroboros_mode

            ouroboros_mode = get_operational_ouroboros_mode()

        if not isinstance(bars_df, pl.DataFrame) or bars_df.is_empty():
            return 0

        # Get trade-ID range of new bars
        new_first = bars_df["first_agg_trade_id"].min()
        new_last = bars_df["last_agg_trade_id"].max()

        if new_first is None or new_last is None or new_first <= 0:
            # No valid trade IDs — fall through to normal insert
            return self.store_bars_batch(
                symbol, threshold_decimal_bps, bars_df,
                version=version, ouroboros_mode=ouroboros_mode,
                _skip_lock=_skip_lock,
            )

        return self._store_bars_replacing_inner(
            symbol, threshold_decimal_bps, bars_df,
            ouroboros_mode=ouroboros_mode, version=version,
            new_first=new_first, new_last=new_last,
            _skip_lock=_skip_lock,
        )

    def _store_bars_replacing_inner(
        self,
        symbol: str,
        threshold_decimal_bps: int,
        bars_df: object,
        *,
        ouroboros_mode: str,
        version: str | None,
        new_first: int,
        new_last: int,
        _skip_lock: bool,
    ) -> int:
        """Inner replace logic — DELETE overlapping bars, INSERT new ones.

        When _skip_lock=False, acquires Redis write lock for the full
        DELETE+INSERT sequence. When _skip_lock=True, caller already
        holds the lock (e.g. kintsugi _repair_under_lock).
        """
        if _skip_lock:
            return self._do_replace(
                symbol, threshold_decimal_bps, bars_df,
                ouroboros_mode=ouroboros_mode, version=version,
                new_first=new_first, new_last=new_last,
            )

        # Issue #158 Phase 7: Hold write lock for full DELETE+INSERT (L1)
        from .write_lock import cache_write_lock

        with cache_write_lock(symbol, threshold_decimal_bps):
            return self._do_replace(
                symbol, threshold_decimal_bps, bars_df,
                ouroboros_mode=ouroboros_mode, version=version,
                new_first=new_first, new_last=new_last,
            )

    def _do_replace(
        self,
        symbol: str,
        threshold_decimal_bps: int,
        bars_df: object,
        *,
        ouroboros_mode: str,
        version: str | None,
        new_first: int,
        new_last: int,
    ) -> int:
        """Atomic DELETE+INSERT for a trade-ID range. Must be called under lock."""
        # Delete bars whose trade-ID range OVERLAPS with the new bars.
        # This catches both fully-contained AND partially-overlapping bars.
        # Partial overlaps occur when a prior broken repair (e.g. killed
        # mega-repair) wrote bars with trade-ID ranges that straddle the
        # boundary of the new bars' range. The fully-contained predicate
        # would leave these stale bars behind, creating Stathera overlaps.
        # The new bars are authoritative — any old bar overlapping their
        # trade-ID range must be replaced.
        delete_sql = (
            "ALTER TABLE opendeviationbar_cache.open_deviation_bars "
            f"DELETE WHERE symbol = '{symbol}' "
            f"AND threshold_decimal_bps = {threshold_decimal_bps} "
            f"AND ouroboros_mode = '{ouroboros_mode}' "
            f"AND first_agg_trade_id <= {new_last} "
            f"AND last_agg_trade_id >= {new_first} "
            "AND first_agg_trade_id > 0"
        )
        self.client.command(delete_sql)
        self._wait_for_mutations(timeout=_net_cfg().clickhouse_mutation_timeout_s)

        # Insert new bars — _skip_lock=True to avoid deadlock
        return self.store_bars_batch(
            symbol, threshold_decimal_bps, bars_df,
            version=version, ouroboros_mode=ouroboros_mode,
            skip_dedup=True,  # Just deleted, no dedup needed
            overlap_strategy="off",  # Just deleted overlapping bars — skip check
            _skip_lock=True,
        )

    def _wait_for_mutations(
        self,
        timeout: int = 300,
        poll_interval: int = 5,
    ) -> None:
        """Poll system.mutations until all pending mutations complete.

        Issue #158: Used after ALTER TABLE DELETE to ensure bars are removed
        before inserting replacements.
        """
        import time

        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            result = self.client.query(
                "SELECT count() FROM system.mutations "
                "WHERE database = 'opendeviationbar_cache' "
                "AND table = 'open_deviation_bars' "
                "AND is_done = 0"
            )
            active = result.result_rows[0][0] if result.result_rows else 0
            if active == 0:
                return
            logger.debug("Waiting for %d active mutation(s)...", active)
            time.sleep(poll_interval)
        logger.warning(
            "Mutation wait timed out after %ds, mutations continue in background",
            timeout,
        )

    def _wait_for_merges(self, timeout: int = 600, poll_interval: int = 10) -> None:
        """Poll system.merges until no active merges for our table.

        Issue #90: After submitting OPTIMIZE, poll ClickHouse to wait for
        background merges to finish. If timeout is reached, log a warning
        and return — merges continue server-side.

        Parameters
        ----------
        timeout : int
            Maximum seconds to wait (default: 600).
        poll_interval : int
            Seconds between polls (default: 10).
        """
        import time

        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            result = self.client.query(
                "SELECT count() FROM system.merges "
                "WHERE database = 'opendeviationbar_cache' "
                "AND table = 'open_deviation_bars'"
            )
            active = result.result_rows[0][0] if result.result_rows else 0
            if active == 0:
                logger.info("All merges complete")
                return
            logger.debug("Waiting for %d active merge(s)...", active)
            time.sleep(poll_interval)
        logger.warning(
            "Merge wait timed out after %ds, merges continue in background",
            timeout,
        )
