from __future__ import annotations

import unittest
from pathlib import Path
import tempfile

from datahub.adapters.tushare import TushareAdapter
from datahub.crypto import SecretVault
from datahub.db import HubDB
from datahub.pipeline import Pipeline
from datahub.settings import Settings
from datahub.serving import V1API
from tests.fixtures import TRADE_DATE, fake_transport

GROUP_A = ("daily", "valuation", "moneyflow", "auction")


class GroupTransport:
    """fake_transport with per-API degradation switches for release-group tests."""

    def __init__(self) -> None:
        self.empty: set[str] = set()
        self.keep_rows: dict[str, int] = {}
        self.stocks: list[dict] | None = None
        self.calls: list[str] = []

    def __call__(self, api_name: str, params: dict, fields: str):
        self.calls.append(api_name)
        if api_name in self.empty:
            return []
        if api_name == "stock_basic" and self.stocks is not None:
            return [dict(row) for row in self.stocks]
        rows = fake_transport(api_name, params, fields)
        keep = self.keep_rows.get(api_name)
        if keep is not None:
            return rows[:keep]
        return rows


def make_pipe(transport: GroupTransport, quality_extra: dict | None = None):
    tmp = tempfile.TemporaryDirectory()
    db = HubDB(Path(tmp.name) / "hub.db")
    adapter = TushareAdapter("test-token", transport=transport)
    quality = {
        "daily_row_ratio": 0.98,
        "null_rate_max": 0.01,
        "max_publish_attempts": 2,
        "publication_generations": 3,
    }
    if quality_extra:
        quality.update(quality_extra)
    settings = Settings(
        encryption_key=SecretVault.generate_key(),
        api_token="t" * 32,
        admin_password="admin-pass",
        tushare_token="test-token",
        db_path=db.path,
        quality=quality,
        scheduler_enabled=False,
    )
    pipe = Pipeline(db, adapter, settings)
    pipe._tmp = tmp
    return pipe, db


def publications_map(db: HubDB, day: str) -> dict[str, str]:
    rows = db.fetchall("SELECT dataset, active_batch FROM publications WHERE trade_date = ?", (day,))
    return {str(row["dataset"]): str(row["active_batch"]) for row in rows}


class ReleaseGroupSwitchTests(unittest.TestCase):
    def setUp(self) -> None:
        self.transport = GroupTransport()
        self.pipe, self.db = make_pipe(self.transport)
        self.pipe.ingest_reference(TRADE_DATE)

    def test_whole_group_switches_in_one_publish_instant(self) -> None:
        results = self.pipe.run_eod_batch_a(TRADE_DATE)
        self.assertEqual(set(results), {*GROUP_A, "stocks"})
        self.assertEqual({item["state"] for item in results.values()}, {"published"})
        pubs = self.db.fetchall("SELECT * FROM publications WHERE trade_date = ?", (TRADE_DATE,))
        self.assertEqual(len(pubs), 5)
        self.assertEqual(len({row["published_at"] for row in pubs}), 1)
        # official rows copied and serving resolves the new batches
        api = V1API(self.db, self.pipe, self.pipe.settings)
        payload = api.handle("/v1/bars/daily", {"date": [TRADE_DATE], "code": ["600000.SH"]})
        self.assertEqual(payload["meta"]["batch_id"], results["daily"]["batch_id"])
        stocks = api.handle("/v1/stocks", {})
        self.assertEqual(stocks["meta"]["batch_id"], results["stocks"]["batch_id"])

    def test_any_member_failure_blocks_entire_group(self) -> None:
        self.transport.empty = {"daily_basic"}  # valuation upstream returns nothing
        results = self.pipe.run_eod_batch_a(TRADE_DATE)
        self.assertEqual(results["valuation"]["state"], "failed")
        self.assertEqual(results["moneyflow"]["state"], "aborted")
        self.assertEqual(results["auction"]["state"], "aborted")
        self.assertEqual(results["daily"]["state"], "failed")  # staged fine, then abandoned
        # nothing became visible, and the reason is recorded
        self.assertEqual(publications_map(self.db, TRADE_DATE), {})
        abandoned = self.db.fetchall(
            "SELECT * FROM batches WHERE trade_date = ? AND state = 'failed'",
            (TRADE_DATE,),
        )
        self.assertTrue(any("release group not switched" in str(row["error"] or "") for row in abandoned))
        audit = self.db.fetchone(
            "SELECT * FROM audit_log WHERE action = 'release-group' ORDER BY id DESC"
        )
        self.assertIn("valuation", str(audit["detail"]))
        # still missing → evening retries keep trying
        self.assertIn("daily", self.pipe.missing_official_datasets(TRADE_DATE))

    def test_failure_keeps_previous_complete_version_serving(self) -> None:
        first = self.pipe.run_dataset("daily", TRADE_DATE)
        self.transport.empty = {"daily_basic"}
        results = self.pipe.run_eod_missing(TRADE_DATE)
        # incomplete A-group restages daily with the others; valuation fails → no A switch
        self.assertEqual(results["daily"]["state"], "failed")
        self.assertEqual(results["valuation"]["state"], "failed")
        # the already-published daily batch is untouched and keeps serving
        self.assertEqual(self.pipe.active_batch("daily", TRADE_DATE), first["batch_id"])
        pubs = publications_map(self.db, TRADE_DATE)
        self.assertEqual(pubs["daily"], first["batch_id"])
        self.assertNotIn("valuation", pubs)
        self.assertNotIn("moneyflow", pubs)
        self.assertNotIn("auction", pubs)
        # B-group is an independent boundary and may still publish
        self.assertEqual(results["index_daily"]["state"], "published")
        payload = V1API(self.db, self.pipe, self.pipe.settings).handle(
            "/v1/bars/daily", {"date": [TRADE_DATE], "code": ["600000.SH"]}
        )
        self.assertEqual(payload["meta"]["batch_id"], first["batch_id"])

    def test_partial_group_retry_does_not_mix_batches(self) -> None:
        """Already-published A members must be restaged with missing ones."""
        first_daily = self.pipe.run_dataset("daily", TRADE_DATE)
        first_moneyflow = self.pipe.run_dataset("moneyflow", TRADE_DATE)
        results = self.pipe.run_eod_missing(TRADE_DATE)
        # A-group switched as one boundary; B-group (index) also published
        for name in (*GROUP_A, "stocks"):
            self.assertEqual(results[name]["state"], "published", name)
        self.assertEqual(results["index_daily"]["state"], "published")
        pubs = self.db.fetchall(
            "SELECT dataset, active_batch, published_at FROM publications WHERE trade_date = ?",
            (TRADE_DATE,),
        )
        by_ds = {str(row["dataset"]): row for row in pubs}
        # old partial batches replaced — no cross-batch mix of the first wave
        self.assertNotEqual(by_ds["daily"]["active_batch"], first_daily["batch_id"])
        self.assertNotEqual(by_ds["moneyflow"]["active_batch"], first_moneyflow["batch_id"])
        a_times = {by_ds[name]["published_at"] for name in (*GROUP_A, "stocks")}
        self.assertEqual(len(a_times), 1)
        # serving resolves the new complete A-group batches
        api = V1API(self.db, self.pipe, self.pipe.settings)
        daily = api.handle("/v1/bars/daily", {"date": [TRADE_DATE], "code": ["600000.SH"]})
        self.assertEqual(daily["meta"]["batch_id"], results["daily"]["batch_id"])
        self.assertEqual(daily["meta"]["batch_id"], by_ds["daily"]["active_batch"])

    def test_reads_during_switch_see_old_state_until_commit(self) -> None:
        snapshots: list[dict] = []

        def watcher() -> None:
            with self.db.connect() as connection:
                rows = connection.execute(
                    "SELECT dataset, active_batch FROM publications WHERE trade_date = ?",
                    (TRADE_DATE,),
                ).fetchall()
                snapshots.append({str(row["dataset"]): row["active_batch"] for row in rows})

        self.pipe.before_commit = watcher
        self.pipe.run_eod_batch_a(TRADE_DATE)
        # inside the switch transaction the group was still invisible
        self.assertEqual(snapshots[0], {})
        after = publications_map(self.db, TRADE_DATE)
        self.assertEqual(set(after), {*GROUP_A, "stocks"})

    def test_switch_crash_rolls_back_whole_group(self) -> None:
        def explode() -> None:
            raise RuntimeError("killed mid-switch")

        self.pipe.before_commit = explode
        with self.assertRaises(RuntimeError):
            self.pipe.run_eod_batch_a(TRADE_DATE)
        self.assertEqual(publications_map(self.db, TRADE_DATE), {})
        for table in ("eod_bars", "eod_valuation", "eod_moneyflow", "eod_auction", "eod_stocks"):
            rows = self.db.fetchall(f"SELECT * FROM {table} WHERE trade_date = ?", (TRADE_DATE,))
            self.assertEqual(rows, [], table)
        audit = self.db.fetchone(
            "SELECT * FROM audit_log WHERE action = 'release-group' ORDER BY id DESC"
        )
        self.assertIsNotNone(audit)
        detail = str(audit["detail"])
        self.assertIn("killed mid-switch", detail)
        self.assertIn("failed", detail)

    def test_duplicate_runs_are_idempotent(self) -> None:
        self.pipe.run_eod_batch_a(TRADE_DATE)
        self.pipe.run_eod_batch_b(TRADE_DATE)
        batches_before = {
            str(row["batch_id"])
            for row in self.db.fetchall("SELECT batch_id FROM batches WHERE trade_date = ?", (TRADE_DATE,))
        }
        calls_before = len(self.transport.calls)
        again = self.pipe.run_eod_missing(TRADE_DATE)
        self.assertEqual({item["state"] for item in again.values()}, {"skipped"})
        self.assertEqual({item["reason"] for item in again.values()}, {"already_published"})
        batches_after = {
            str(row["batch_id"])
            for row in self.db.fetchall("SELECT batch_id FROM batches WHERE trade_date = ?", (TRADE_DATE,))
        }
        self.assertEqual(batches_after, batches_before)
        self.assertEqual(len(self.transport.calls), calls_before)
        self.assertEqual(self.pipe.missing_official_datasets(TRADE_DATE), [])

    def test_cross_gate_failure_blocks_switch(self) -> None:
        transport = GroupTransport()
        pipe, db = make_pipe(
            transport,
            quality_extra={"cross_gates": [
                {"left": "daily", "right": "moneyflow", "min_key_overlap": 1.0},
            ]},
        )
        pipe.ingest_reference(TRADE_DATE)
        transport.keep_rows["moneyflow"] = 1  # moneyflow covers only half the market
        results = pipe.run_eod_batch_a(TRADE_DATE)
        self.assertEqual(results["moneyflow"]["state"], "failed")
        self.assertIn("cross gate", str(results["moneyflow"]["error"]))
        self.assertEqual(publications_map(db, TRADE_DATE), {})

    def test_stocks_master_and_snapshot_switch_together_or_not_at_all(self) -> None:
        original = [
            {"ts_code": "600000.SH", "symbol": "600000", "name": "浦发银行", "area": "上海",
             "industry": "银行", "market": "主板", "list_status": "L", "list_date": "19991110"},
            {"ts_code": "920071.BJ", "symbol": "920071", "name": "N金钛", "area": "辽宁",
             "industry": "小金属", "market": "北交所", "list_status": "L", "list_date": "20240901"},
        ]
        renamed = [dict(original[0]), {**original[1], "name": "金钛股份"}]
        self.transport.stocks = renamed
        self.pipe.run_eod_batch_a(TRADE_DATE)
        master = self.db.fetchone("SELECT name FROM stock_master WHERE ts_code = '920071.BJ'")
        self.assertEqual(master["name"], "金钛股份")
        stocks_pub = self.db.fetchone(
            "SELECT active_batch FROM publications WHERE dataset = 'stocks' AND trade_date = ?",
            (TRADE_DATE,),
        )
        self.assertIsNotNone(stocks_pub)

        # failure path: rename staged but the group is blocked → master stays untouched
        transport = GroupTransport()
        transport.stocks = original
        pipe, db = make_pipe(
            transport,
            quality_extra={"cross_gates": [
                {"left": "daily", "right": "moneyflow", "min_key_overlap": 1.0},
            ]},
        )
        pipe.ingest_reference(TRADE_DATE)  # master seeded with "N金钛"
        transport.stocks = renamed
        transport.keep_rows["moneyflow"] = 1
        results = pipe.run_eod_batch_a(TRADE_DATE)
        self.assertEqual(results["stocks"]["state"], "failed")
        master = db.fetchone("SELECT name FROM stock_master WHERE ts_code = '920071.BJ'")
        self.assertEqual(master["name"], "N金钛")  # rename not applied
        stocks_pub = db.fetchone(
            "SELECT active_batch FROM publications WHERE dataset = 'stocks' AND trade_date = ?",
            (TRADE_DATE,),
        )
        self.assertIsNone(stocks_pub)


class StocksRefreshAtomicTests(unittest.TestCase):
    def setUp(self) -> None:
        self.transport = GroupTransport()
        self.pipe, self.db = make_pipe(self.transport)
        self.pipe.ingest_reference(TRADE_DATE)
        self.transport.stocks = [
            {"ts_code": "600000.SH", "symbol": "600000", "name": "浦发银行", "area": "上海",
             "industry": "银行", "market": "主板", "list_status": "L", "list_date": "19991110"},
            {"ts_code": "920071.BJ", "symbol": "920071", "name": "N金钛", "area": "辽宁",
             "industry": "小金属", "market": "北交所", "list_status": "L", "list_date": "20240901"},
        ]
        first = self.pipe.refresh_stocks(TRADE_DATE)
        self.assertEqual(first["state"], "published")
        self.first_batch = first["batch_id"]

    def test_refresh_keeps_master_when_snapshot_publish_fails(self) -> None:
        self.transport.stocks = [
            {"ts_code": "600000.SH", "symbol": "600000", "name": "浦发银行", "area": "上海",
             "industry": "银行", "market": "主板", "list_status": "L", "list_date": "19991110"},
            {"ts_code": "920071.BJ", "symbol": "920071", "name": "金钛股份", "area": "辽宁",
             "industry": "小金属", "market": "北交所", "list_status": "L", "list_date": "20240901"},
        ]

        def explode() -> None:
            raise RuntimeError("snapshot switch killed")

        self.pipe.before_commit = explode
        with self.assertRaises(RuntimeError):
            self.pipe.refresh_stocks(TRADE_DATE)
        master = self.db.fetchone("SELECT name FROM stock_master WHERE ts_code = '920071.BJ'")
        self.assertEqual(master["name"], "N金钛")  # rename not applied
        self.assertEqual(self.pipe.active_batch("stocks", TRADE_DATE), self.first_batch)
        audit = self.db.fetchone(
            "SELECT * FROM audit_log WHERE action = 'stocks-refresh' ORDER BY id DESC"
        )
        self.assertIn("failed", str(audit["detail"]))
        self.assertIn("snapshot switch killed", str(audit["detail"]))

    def test_refresh_keeps_master_when_quality_gate_rejects(self) -> None:
        self.transport.stocks = []  # empty → hard fail before publish
        with self.assertRaises(Exception):
            self.pipe.refresh_stocks(TRADE_DATE)
        master = self.db.fetchone("SELECT name FROM stock_master WHERE ts_code = '920071.BJ'")
        self.assertEqual(master["name"], "N金钛")
        self.assertEqual(self.pipe.active_batch("stocks", TRADE_DATE), self.first_batch)
        audit = self.db.fetchone(
            "SELECT * FROM audit_log WHERE action = 'stocks-refresh' ORDER BY id DESC"
        )
        self.assertIn("failed", str(audit["detail"]))


class ForceBoundaryEntryTests(unittest.TestCase):
    """CLI force / admin backfill must rebuild the full A/B boundary."""

    def setUp(self) -> None:
        self.transport = GroupTransport()
        self.pipe, self.db = make_pipe(self.transport)
        self.pipe.ingest_reference(TRADE_DATE)
        self.first = self.pipe.run_eod_batch_a(TRADE_DATE)
        self.pipe.run_eod_batch_b(TRADE_DATE)

    def test_force_republish_valuation_rebuilds_whole_a_group(self) -> None:
        before = publications_map(self.db, TRADE_DATE)
        results = self.pipe.force_republish_boundary("valuation", TRADE_DATE)
        self.assertEqual({item["state"] for item in results.values()}, {"published"})
        after = publications_map(self.db, TRADE_DATE)
        for name in (*GROUP_A, "stocks"):
            self.assertNotEqual(after[name], before[name], name)
            self.assertEqual(after[name], results[name]["batch_id"], name)
        # B-group left alone
        self.assertEqual(after["index_daily"], before["index_daily"])
        pubs = self.db.fetchall(
            "SELECT dataset, published_at FROM publications WHERE trade_date = ?",
            (TRADE_DATE,),
        )
        a_times = {row["published_at"] for row in pubs if row["dataset"] in {*GROUP_A, "stocks"}}
        self.assertEqual(len(a_times), 1)

    def test_force_republish_index_rebuilds_only_b_group(self) -> None:
        before = publications_map(self.db, TRADE_DATE)
        results = self.pipe.force_republish_boundary("index_daily", TRADE_DATE)
        self.assertEqual(results["index_daily"]["state"], "published")
        after = publications_map(self.db, TRADE_DATE)
        self.assertNotEqual(after["index_daily"], before["index_daily"])
        for name in GROUP_A:
            self.assertEqual(after[name], before[name], name)

    def test_admin_backfill_official_dataset_uses_boundary(self) -> None:
        from datahub.admin_api import AdminAPI
        from datahub.auth import AuthService
        from datahub.crypto import SecretVault
        from datahub.scheduler import Scheduler
        from datahub.serving import ApiError

        vault = SecretVault(self.pipe.settings.encryption_key)
        auth = AuthService(self.db, vault, self.pipe.settings.api_token, "StartPass1")
        admin = AdminAPI(self.db, self.pipe, Scheduler(self.db, self.pipe), auth)
        before = publications_map(self.db, TRADE_DATE)
        result = admin.backfill("moneyflow", TRADE_DATE, "StartPass1", f"moneyflow:{TRADE_DATE}", "tester")
        self.assertEqual(result["moneyflow"]["state"], "published")
        after = publications_map(self.db, TRADE_DATE)
        for name in (*GROUP_A, "stocks"):
            self.assertNotEqual(after[name], before[name], name)
        # bad password / wrong confirm still rejected
        with self.assertRaises(ApiError):
            admin.backfill("daily", TRADE_DATE, "wrong", f"daily:{TRADE_DATE}", "tester")

    def test_admin_backfill_switch_crash_is_failed_precondition(self) -> None:
        from datahub.admin_api import AdminAPI
        from datahub.auth import AuthService
        from datahub.crypto import SecretVault
        from datahub.scheduler import Scheduler
        from datahub.serving import ApiError

        vault = SecretVault(self.pipe.settings.encryption_key)
        auth = AuthService(self.db, vault, self.pipe.settings.api_token, "StartPass1")
        admin = AdminAPI(self.db, self.pipe, Scheduler(self.db, self.pipe), auth)
        before = publications_map(self.db, TRADE_DATE)

        def explode() -> None:
            raise RuntimeError("killed mid-switch")

        self.pipe.before_commit = explode
        with self.assertRaises(ApiError) as ctx:
            admin.backfill("valuation", TRADE_DATE, "StartPass1", f"valuation:{TRADE_DATE}", "tester")
        self.assertEqual(ctx.exception.code, "FAILED_PRECONDITION")
        self.assertIn("killed mid-switch", ctx.exception.message)
        # previous complete A/B versions keep serving
        self.assertEqual(publications_map(self.db, TRADE_DATE), before)
        audit = self.db.fetchone(
            "SELECT * FROM audit_log WHERE action = 'release-group' ORDER BY id DESC"
        )
        self.assertIsNotNone(audit)
        self.assertIn("failed", str(audit["detail"]))
        self.assertIn("killed mid-switch", str(audit["detail"]))


if __name__ == "__main__":
    unittest.main()
