"""Canonical snapshot build orchestration."""
from __future__ import annotations

import datetime as dt
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any

import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.parquet as pq

from libs.backtest.snapshots import (
    canonical_snapshot_dir,
    get_canonical_spec,
    get_requested_aliases,
    resolve_snapshot,
)
from libs.backtest.snapshot_store import SnapshotStore
from libs.common.config import get_settings
from libs.common.logging import get_logger
from libs.common.time_utils import utc_now
from libs.db.session import get_session
from libs.export.snapshot_export import export_dataset_snapshot

logger = get_logger(__name__)

_ENRICHMENT_SCRIPT_BY_STEP = {
    "earnings_history_enrich": Path("scripts/enrich_earnings_history_features.py"),
    "peer_surprise_enrich": Path("scripts/enrich_peer_surprise_features.py"),
    "catalyst_persistence_enrich": Path("scripts/enrich_catalyst_persistence_features.py"),
    "tier2_enrich": Path("scripts/enrich_tier2_features.py"),
    "tier3_enrich": Path("scripts/enrich_tier3_features.py"),
    "technical_enrich": Path("scripts/enrich_technical_features.py"),
    "macro_enrich": Path("scripts/enrich_macro_features.py"),
    "prior_drift_enrich": Path("scripts/enrich_prior_drift.py"),
}


def _coverage_end_date(snapshot_dir: Path) -> str | None:
    max_date: dt.date | None = None
    for split_name in ("train", "valid", "test"):
        parquet_path = snapshot_dir / f"{split_name}.parquet"
        if not parquet_path.exists():
            continue
        table = pq.read_table(str(parquet_path))
        if "event_date" not in table.column_names:
            continue
        table = table.select(["event_date"])
        for value in table.column("event_date").to_pylist():
            if isinstance(value, str):
                candidate = dt.date.fromisoformat(value[:10])
            elif isinstance(value, dt.datetime):
                candidate = value.date()
            elif isinstance(value, dt.date):
                candidate = value
            else:
                continue
            if max_date is None or candidate > max_date:
                max_date = candidate
    return max_date.isoformat() if max_date is not None else None


def _write_canonical_manifest(
    snapshot_dir: Path,
    canonical_snapshot_id: str,
    spec: dict[str, Any],
    extra_fields: dict[str, Any] | None = None,
) -> None:
    manifest_path = snapshot_dir / "manifest.json"
    manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
    manifest.update(
        {
            "snapshot_id": canonical_snapshot_id,
            "canonical_snapshot_id": canonical_snapshot_id,
            "output_dir": str(snapshot_dir.resolve()),
            "feature_sets": list(spec.get("feature_sets") or []),
            "enrichment_steps": list(spec.get("enrichment_steps") or []),
            "requested_aliases": get_requested_aliases(canonical_snapshot_id),
            "coverage_end_date": _coverage_end_date(snapshot_dir),
            "last_refresh_utc": utc_now().isoformat(),
            "refresh_policy": spec.get("refresh_policy"),
            "purpose": spec.get("purpose"),
            "expected_feature_columns": list(spec.get("expected_feature_columns") or []),
            "universe_profile": spec.get("universe_profile"),
            "start_date": spec.get("start_date"),
            "end_date": spec.get("end_date"),
        }
    )
    if extra_fields:
        manifest.update(extra_fields)
    manifest_path.write_text(json.dumps(manifest, indent=2))


def _run_enrichment_step(step_name: str, input_dir: Path, output_dir: Path) -> None:
    script_path = _ENRICHMENT_SCRIPT_BY_STEP[step_name]
    cmd = [
        sys.executable,
        str(script_path),
        "--input",
        str(input_dir),
        "--output",
        str(output_dir),
    ]
    logger.info("canonical_snapshot_enrichment_start", step=step_name, cmd=cmd)
    env = {**__import__("os").environ, "PYTHONUNBUFFERED": "1"}
    subprocess.run(cmd, check=True, env=env)


async def _materialize_runtime_backfills(snapshot_dir: Path) -> list[str]:
    settings = get_settings()
    return await SnapshotStore._async_materialize_snapshot_dir(
        snapshot_dir,
        oracle_url=settings.stock_oracle_url,
        db_dsn=settings.postgres_dsn,
    )


def _atomic_swap_directory(staged_final_dir: Path, target_dir: Path) -> None:
    backup_dir = target_dir.with_name(f"{target_dir.name}.bak.{utc_now().strftime('%Y%m%d%H%M%S%f')}")
    backup_created = False
    try:
        if target_dir.exists():
            target_dir.rename(backup_dir)
            backup_created = True
        staged_final_dir.rename(target_dir)
    except Exception:
        if backup_created and not target_dir.exists() and backup_dir.exists():
            backup_dir.rename(target_dir)
        raise
    else:
        if backup_created and backup_dir.exists():
            shutil.rmtree(backup_dir)


def _resolve_bootstrap_source_dir(source_snapshot_id: str) -> Path:
    """Resolve an existing snapshot dir for bootstrap from managed roots."""
    source_path = Path(source_snapshot_id)
    if source_path.is_absolute() and source_path.exists():
        return source_path

    settings = get_settings()
    candidate_roots = [
        Path(settings.parquet_dir),
        Path("data/datasets/snapshots"),
    ]
    for root in candidate_roots:
        candidate = root / source_snapshot_id
        if candidate.exists():
            return candidate.resolve()
    raise FileNotFoundError(f"Bootstrap source snapshot not found: {source_snapshot_id}")


async def build_canonical_snapshot(
    requested_snapshot_id: str,
    *,
    manual: bool = False,
) -> Path:
    """Build a registry-managed canonical snapshot via full rebuild + atomic swap."""
    resolution = resolve_snapshot(requested_snapshot_id, allow_legacy_passthrough=False)
    canonical_snapshot_id = resolution.canonical_snapshot_id
    spec = get_canonical_spec(canonical_snapshot_id)

    refresh_policy = str(spec.get("refresh_policy") or "")
    if refresh_policy == "manual_only" and not manual:
        raise RuntimeError(f"Snapshot '{canonical_snapshot_id}' is frozen for auto-refresh.")

    target_dir = canonical_snapshot_dir(canonical_snapshot_id)
    target_dir.parent.mkdir(parents=True, exist_ok=True)

    with tempfile.TemporaryDirectory(
        prefix=f".{canonical_snapshot_id}.stage.",
        dir=str(target_dir.parent),
    ) as stage_root_str:
        stage_root = Path(stage_root_str)
        current_dir = stage_root / "00_base"

        start_date = spec.get("start_date")
        end_date = spec.get("end_date")
        async with get_session() as session:
            await export_dataset_snapshot(
                session=session,
                snapshot_id=current_dir.name,
                split_policy="temporal_70_15_15",
                output_dir=stage_root,
                feature_versions=list(spec.get("feature_versions") or []),
                universe_profile=spec.get("universe_profile"),
                label_version=spec.get("label_version", "label-1.0.0"),
                start_date=dt.date.fromisoformat(start_date) if start_date else None,
                end_date=dt.date.fromisoformat(end_date) if end_date else None,
                include_export_enrichments=False,
            )

        steps = [step for step in list(spec.get("enrichment_steps") or []) if step != "base_export"]
        for idx, step in enumerate(steps, start=1):
            is_last = idx == len(steps)
            next_dir = stage_root / (canonical_snapshot_id if is_last else f"{idx:02d}_{step}")
            _run_enrichment_step(step, current_dir, next_dir)
            current_dir = next_dir

        if current_dir.name != canonical_snapshot_id:
            final_dir = stage_root / canonical_snapshot_id
            shutil.copytree(current_dir, final_dir)
            current_dir = final_dir

        materialized_columns = await _materialize_runtime_backfills(current_dir)
        _write_canonical_manifest(
            current_dir,
            canonical_snapshot_id,
            spec,
            extra_fields={
                "materialized_feature_columns": materialized_columns,
                "materialization_mode": "runtime_backfill_persisted_v1",
            },
        )
        _atomic_swap_directory(current_dir, target_dir)

    return target_dir


async def bootstrap_canonical_snapshot(
    requested_snapshot_id: str,
    *,
    source_snapshot_id: str,
    enrichment_steps: list[str] | None = None,
) -> Path:
    """Materialize a canonical snapshot from an existing local snapshot directory.

    This is a parity-preserving migration path for phase 1 when the legacy
    enriched snapshot is the current source of truth.
    """
    resolution = resolve_snapshot(requested_snapshot_id, allow_legacy_passthrough=False)
    canonical_snapshot_id = resolution.canonical_snapshot_id
    spec = get_canonical_spec(canonical_snapshot_id)

    source_dir = _resolve_bootstrap_source_dir(source_snapshot_id)

    target_dir = canonical_snapshot_dir(canonical_snapshot_id)
    target_dir.parent.mkdir(parents=True, exist_ok=True)

    with tempfile.TemporaryDirectory(
        prefix=f".{canonical_snapshot_id}.bootstrap.",
        dir=str(target_dir.parent),
    ) as stage_root_str:
        stage_root = Path(stage_root_str)
        current_dir = stage_root / canonical_snapshot_id
        shutil.copytree(source_dir, current_dir)

        for idx, step in enumerate(list(enrichment_steps or []), start=1):
            is_last = idx == len(list(enrichment_steps or []))
            next_dir = stage_root / (canonical_snapshot_id if is_last else f"bootstrap_{idx:02d}_{step}")
            _run_enrichment_step(step, current_dir, next_dir)
            current_dir = next_dir

        materialized_columns = await _materialize_runtime_backfills(current_dir)
        _write_canonical_manifest(
            current_dir,
            canonical_snapshot_id,
            spec,
            extra_fields={
                "bootstrap_source_snapshot_id": source_snapshot_id,
                "bootstrap_mode": "copy_existing_snapshot",
                "materialized_feature_columns": materialized_columns,
                "materialization_mode": "runtime_backfill_persisted_v1",
            },
        )
        _atomic_swap_directory(current_dir, target_dir)

    return target_dir


async def incremental_update_canonical_snapshot(
    requested_snapshot_id: str,
) -> Path:
    """Append only new events (since coverage_end_date) from DB to existing canonical.

    Does not rebuild existing rows. Runs enrichment scripts only on new rows.
    New rows are appended to the test split (they are always the most recent events).
    """
    resolution = resolve_snapshot(requested_snapshot_id, allow_legacy_passthrough=False)
    canonical_snapshot_id = resolution.canonical_snapshot_id
    spec = get_canonical_spec(canonical_snapshot_id)
    target_dir = canonical_snapshot_dir(canonical_snapshot_id)

    if not target_dir.exists():
        raise FileNotFoundError(f"Canonical snapshot not found: {target_dir}")

    # Determine start_date for new events
    max_date_str = _coverage_end_date(target_dir)
    if max_date_str is None:
        raise ValueError("Cannot determine coverage_end_date from existing canonical")
    new_start = dt.date.fromisoformat(max_date_str) + dt.timedelta(days=1)

    logger.info(
        "incremental_update_canonical_start",
        canonical_snapshot_id=canonical_snapshot_id,
        existing_coverage_end=max_date_str,
        new_start=str(new_start),
    )

    with tempfile.TemporaryDirectory(
        prefix=f".{canonical_snapshot_id}.incremental.",
        dir=str(target_dir.parent),
    ) as stage_root_str:
        stage_root = Path(stage_root_str)

        # 1. Export new events from DB (start_date = day after current coverage)
        end_date_str = spec.get("end_date")
        async with get_session() as session:
            await export_dataset_snapshot(
                session=session,
                snapshot_id="new_events",
                split_policy="temporal_70_15_15",
                output_dir=stage_root,
                feature_versions=list(spec.get("feature_versions") or []),
                universe_profile=spec.get("universe_profile"),
                label_version=spec.get("label_version", "label-1.0.0"),
                start_date=new_start,
                end_date=dt.date.fromisoformat(end_date_str) if end_date_str else None,
                include_export_enrichments=False,
            )

        new_export_dir = stage_root / "new_events"

        # Combine all exported splits into one batch (all new rows are recent → go to test)
        all_new_tables = []
        for split in ("train", "valid", "test"):
            p = new_export_dir / f"{split}.parquet"
            if p.exists():
                t = pq.read_table(str(p))
                if len(t) > 0:
                    all_new_tables.append(t)

        if not all_new_tables:
            logger.info("incremental_update_no_new_events", new_start=str(new_start))
            return target_dir

        all_new_rows = pa.concat_tables(all_new_tables, promote_options="default")
        total_new = len(all_new_rows)
        logger.info("incremental_update_new_rows_exported", count=total_new, new_start=str(new_start))

        # 2. Write new rows to enrichment input dir (all as test split, empty train/valid)
        enrich_input_dir = stage_root / "00_new_base"
        enrich_input_dir.mkdir()
        empty = all_new_rows.schema.empty_table()
        pq.write_table(empty, enrich_input_dir / "train.parquet")
        pq.write_table(empty, enrich_input_dir / "valid.parquet")
        pq.write_table(all_new_rows, enrich_input_dir / "test.parquet")
        # Copy manifest from export
        manifest_src = new_export_dir / "manifest.json"
        if manifest_src.exists():
            shutil.copy2(manifest_src, enrich_input_dir / "manifest.json")

        # 3. Run enrichment steps on new rows only
        steps = [s for s in list(spec.get("enrichment_steps") or []) if s != "base_export"]
        current_dir = enrich_input_dir
        for idx, step in enumerate(steps, start=1):
            is_last = idx == len(steps)
            next_dir = stage_root / (f"enrich_{canonical_snapshot_id}" if is_last else f"enrich_{idx:02d}_{step}")
            _run_enrichment_step(step, current_dir, next_dir)
            current_dir = next_dir

        if current_dir.name != f"enrich_{canonical_snapshot_id}":
            final_enrich_dir = stage_root / f"enrich_{canonical_snapshot_id}"
            shutil.copytree(current_dir, final_enrich_dir)
            current_dir = final_enrich_dir

        # 4. Materialize runtime backfills for new rows
        await _materialize_runtime_backfills(current_dir)

        # 5. Load enriched new test rows
        new_test = pq.read_table(str(current_dir / "test.parquet"))
        logger.info("incremental_update_new_rows_enriched", count=len(new_test))

        # 6. Load existing canonical splits
        existing_train = pq.read_table(str(target_dir / "train.parquet"))
        existing_valid = pq.read_table(str(target_dir / "valid.parquet"))
        existing_test = pq.read_table(str(target_dir / "test.parquet"))

        # 7. Append new rows to test split; align schemas (new columns get null in old rows)
        merged_test = pa.concat_tables([existing_test, new_test], promote_options="default")
        if "event_date" in merged_test.column_names:
            sort_idx = pc.sort_indices(merged_test, sort_keys=[("event_date", "ascending")])
            merged_test = merged_test.take(sort_idx)

        # 8. Write merged canonical to staging
        merged_dir = stage_root / canonical_snapshot_id
        merged_dir.mkdir()
        pq.write_table(existing_train, str(merged_dir / "train.parquet"))
        pq.write_table(existing_valid, str(merged_dir / "valid.parquet"))
        pq.write_table(merged_test, str(merged_dir / "test.parquet"))

        # 9. Write manifest and atomic swap
        _write_canonical_manifest(merged_dir, canonical_snapshot_id, spec)
        _atomic_swap_directory(merged_dir, target_dir)

    logger.info(
        "incremental_update_canonical_done",
        canonical_snapshot_id=canonical_snapshot_id,
        new_rows_added=total_new,
    )
    return target_dir


async def materialize_canonical_snapshot(
    requested_snapshot_id: str,
) -> Path:
    """Persist runtime-backfilled features into an existing canonical snapshot."""
    resolution = resolve_snapshot(requested_snapshot_id, allow_legacy_passthrough=False)
    canonical_snapshot_id = resolution.canonical_snapshot_id
    spec = get_canonical_spec(canonical_snapshot_id)
    target_dir = canonical_snapshot_dir(canonical_snapshot_id)
    if not target_dir.exists():
        raise FileNotFoundError(f"Canonical snapshot not found: {target_dir}")

    with tempfile.TemporaryDirectory(
        prefix=f".{canonical_snapshot_id}.materialize.",
        dir=str(target_dir.parent),
    ) as stage_root_str:
        stage_root = Path(stage_root_str)
        current_dir = stage_root / canonical_snapshot_id
        shutil.copytree(target_dir, current_dir)
        materialized_columns = await _materialize_runtime_backfills(current_dir)
        _write_canonical_manifest(
            current_dir,
            canonical_snapshot_id,
            spec,
            extra_fields={
                "materialized_feature_columns": materialized_columns,
                "materialization_mode": "runtime_backfill_persisted_v1",
            },
        )
        _atomic_swap_directory(current_dir, target_dir)

    return target_dir
