# 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:
        """INSERT-only replace using RMT(computed_at) native dedup. Inserts new bars first, then detects and cleans up any straddle bars with targeted single-row DELETEs.

        Issue #158: Stathera — used by kintsugi repairs where the writer is
        authoritative. INSERT-first strategy means crash at any point = no data
        loss (worst case: temporary Stathera overlap that kintsugi repairs).

        Issue #158 Phase 7: Wraps the full INSERT+cleanup 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 — INSERT new bars, then targeted straddle cleanup.

        When _skip_lock=False, acquires Redis write lock for the full
        INSERT+cleanup 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:
        """INSERT-only replace using RMT(computed_at) native dedup.

        INSERT-then-verify strategy (v13.39+):
        1. INSERT new bars — RMT keeps the row with the latest computed_at
           for each unique (symbol, threshold, ouroboros, first_agg_trade_id).
           No blanket DELETE = no mutation = no 300s timeout.
        2. Detect straddle bars — old bars with a different first_agg_trade_id
           whose trade-ID range overlaps the new bars. These can't be deduped
           by RMT (different ORDER BY key) and must be cleaned up.
        3. Targeted single-row DELETE for each straddle bar (if any) — touches
           exactly one ClickHouse part, completes in sub-milliseconds.

        Crash safety: INSERT happens first. Kill at any point = no data loss.
        Worst case is a temporary Stathera overlap that kintsugi repairs next pass.
        This is strictly safer than the prior DELETE-then-INSERT approach where
        a kill between DELETE and INSERT caused data loss.
        """
        import polars as pl

        # Step 1: INSERT new bars. RMT(computed_at) handles same-key dedup.
        rows_inserted = self.store_bars_batch(
            symbol, threshold_decimal_bps, bars_df,
            version=version, ouroboros_mode=ouroboros_mode,
            skip_dedup=True,
            overlap_strategy="off",
            _skip_lock=True,
        )

        if rows_inserted == 0:
            return 0

        # Step 2: Detect straddle bars (cheap FINAL point query, ~33ms on 6M rows).
        stale_tids = self._find_straddle_bars(
            symbol, threshold_decimal_bps, ouroboros_mode,
            new_first, new_last,
            bars_df["first_agg_trade_id"].to_list() if isinstance(bars_df, pl.DataFrame) else [],
        )

        # Step 3: Targeted single-row DELETE for each straddle (typically 0).
        # Wrapped in try/except so a failed DELETE does not crash the operation
        # — the INSERT already succeeded, and stale straddle bars will be
        # cleaned up by kintsugi on the next pass or by RMT merge.
        for stale_tid in stale_tids:
            try:
                logger.info(
                    "straddle cleanup: %s@%d first_tid=%d (INSERT-only replace)",
                    symbol, threshold_decimal_bps, stale_tid,
                )
                self.client.command(
                    "DELETE FROM opendeviationbar_cache.open_deviation_bars "
                    f"WHERE symbol = '{symbol}' "
                    f"AND threshold_decimal_bps = {threshold_decimal_bps} "
                    f"AND ouroboros_mode = '{ouroboros_mode}' "
                    f"AND first_agg_trade_id = {stale_tid} "
                    "SETTINGS lightweight_mutation_projection_mode = 'drop'"
                )
            except Exception:
                logger.warning(
                    "straddle DELETE failed for %s@%d first_tid=%d — "
                    "INSERT already succeeded, kintsugi will clean up",
                    symbol, threshold_decimal_bps, stale_tid,
                    exc_info=True,
                )

        return rows_inserted

    def _find_straddle_bars(
        self,
        symbol: str,
        threshold_decimal_bps: int,
        ouroboros_mode: str,
        new_first: int,
        new_last: int,
        new_bar_first_tids: list[int],
    ) -> list[int]:
        """Find stale bars that straddle the new bars' trade-ID range.

        Returns first_agg_trade_id values of bars that:
        1. Left straddle: start before new_first but extend into the new range
        2. Interior stale: start inside the new range but don't match any new bar

        Both cases have different ORDER BY keys from the new bars, so RMT
        cannot dedup them. They need targeted single-row DELETE.

        Typically returns empty list (0 straddle bars in normal operation).
        """
        stale_tids: list[int] = []

        # Left straddle: old bar starts before our range, extends into it
        result = self.client.query(
            """
            SELECT first_agg_trade_id
            FROM opendeviationbar_cache.open_deviation_bars FINAL
            WHERE symbol = {symbol:String}
              AND threshold_decimal_bps = {threshold:UInt32}
              AND ouroboros_mode = {ouroboros:String}
              AND first_agg_trade_id > 0
              AND first_agg_trade_id < {new_first:Int64}
              AND last_agg_trade_id >= {new_first:Int64}
            ORDER BY first_agg_trade_id DESC
            LIMIT 10
            """,
            parameters={
                "symbol": symbol,
                "threshold": threshold_decimal_bps,
                "ouroboros": ouroboros_mode,
                "new_first": new_first,
            },
        )
        for row in result.result_rows:
            stale_tids.append(row[0])

        # Interior stale: old bar inside our range but not one of our new bars.
        # Only check if we have the new bar first_tids (bounded by bars/day, ~50-500).
        if new_bar_first_tids:
            tids_csv = ",".join(str(t) for t in new_bar_first_tids)
            result = self.client.query(
                f"""
                SELECT first_agg_trade_id
                FROM opendeviationbar_cache.open_deviation_bars FINAL
                WHERE symbol = {{symbol:String}}
                  AND threshold_decimal_bps = {{threshold:UInt32}}
                  AND ouroboros_mode = {{ouroboros:String}}
                  AND first_agg_trade_id > 0
                  AND first_agg_trade_id >= {{new_first:Int64}}
                  AND first_agg_trade_id <= {{new_last:Int64}}
                  AND first_agg_trade_id NOT IN ({tids_csv})
                """,
                parameters={
                    "symbol": symbol,
                    "threshold": threshold_decimal_bps,
                    "ouroboros": ouroboros_mode,
                    "new_first": new_first,
                    "new_last": new_last,
                },
            )
            for row in result.result_rows:
                stale_tids.append(row[0])

        return stale_tids

    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,
        )
