"""
Quant (Factor & Model) workflow with session control
"""

import asyncio
import json
from pathlib import Path
from typing import Any

import fire
from rdagent.app.qlib_rd_loop.conf import QUANT_PROP_SETTING
from rdagent.components.workflow.conf import BasePropSetting
from rdagent.components.workflow.rd_loop import RDLoop
from rdagent.core.conf import RD_AGENT_SETTINGS
from rdagent.core.developer import Developer
from rdagent.core.exception import FactorEmptyError, LLMUnavailableError, ModelEmptyError
from rdagent.core.proposal import (
    Experiment2Feedback,
    ExperimentPlan,
    Hypothesis2Experiment,
    HypothesisFeedback,
    HypothesisGen,
)
from rdagent.core.scenario import Scenario
from rdagent.core.utils import import_class
from rdagent.log import rdagent_logger as logger
from rdagent.scenarios.qlib.proposal.quant_proposal import QuantTrace
from rdagent.utils.qlib import ALPHA20


class QuantRDLoop(RDLoop):
    skip_loop_error = (
        FactorEmptyError,
        ModelEmptyError,
        LLMUnavailableError,  # LLM timeout after all retries → skip loop, don't crash
    )

    def __init__(self, PROP_SETTING: BasePropSetting):
        scen: Scenario = import_class(PROP_SETTING.scen)()
        logger.log_object(scen, tag="scenario")

        self.hypothesis_gen: HypothesisGen = import_class(PROP_SETTING.quant_hypothesis_gen)(scen)
        logger.log_object(self.hypothesis_gen, tag="quant hypothesis generator")

        self.factor_hypothesis2experiment: Hypothesis2Experiment = import_class(
            PROP_SETTING.factor_hypothesis2experiment,
        )()
        logger.log_object(self.factor_hypothesis2experiment, tag="factor hypothesis2experiment")
        self.model_hypothesis2experiment: Hypothesis2Experiment = import_class(
            PROP_SETTING.model_hypothesis2experiment,
        )()
        logger.log_object(self.model_hypothesis2experiment, tag="model hypothesis2experiment")

        self.factor_coder: Developer = import_class(PROP_SETTING.factor_coder)(scen)
        logger.log_object(self.factor_coder, tag="factor coder")
        self.model_coder: Developer = import_class(PROP_SETTING.model_coder)(scen)
        logger.log_object(self.model_coder, tag="model coder")

        self.factor_runner: Developer = import_class(PROP_SETTING.factor_runner)(scen)
        logger.log_object(self.factor_runner, tag="factor runner")
        self.model_runner: Developer = import_class(PROP_SETTING.model_runner)(scen)
        logger.log_object(self.model_runner, tag="model runner")

        self.factor_summarizer: Experiment2Feedback = import_class(PROP_SETTING.factor_summarizer)(scen)
        logger.log_object(self.factor_summarizer, tag="factor summarizer")
        self.model_summarizer: Experiment2Feedback = import_class(PROP_SETTING.model_summarizer)(scen)
        logger.log_object(self.model_summarizer, tag="model summarizer")

        self.plan: ExperimentPlan = {
            "features": ALPHA20,
            "feature_codes": {},
        }  # for user interaction
        self.trace = QuantTrace(scen=scen)
        super(RDLoop, self).__init__()

    def _ensure_kronos_factors_in_pool(self) -> None:
        """Generate Kronos foundation model factors with varying prediction horizons.

        Generates KronosPredReturn_p24, KronosPredReturn_p48, KronosPredReturn_p96
        if they don't already exist in results/factors/.  Uses CPU inference so it
        co-exists peacefully with the llama-server GPU process.
        """
        import json as _json
        from datetime import datetime as _dt
        from pathlib import Path as _Path

        data_path = _Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
        if not data_path.exists():
            logger.warning("Kronos: intraday_pv.h5 missing, skipping factor generation")
            return

        factors_dir = _Path("results/factors")
        values_dir = factors_dir / "values"

        for pred_bars in (24, 48, 96):
            factor_name = f"KronosPredReturn_p{pred_bars}"
            json_path = factors_dir / f"{factor_name}.json"
            parquet_path = values_dir / f"{factor_name}.parquet"

            if json_path.exists() and parquet_path.exists():
                try:
                    existing = _json.loads(json_path.read_text())
                    if existing.get("ic") is not None and existing.get("model_size") == "small":
                        logger.info(f"Kronos: {factor_name} exists (IC={existing['ic']:.4f}), skip")
                        continue
                except Exception:
                    pass

            try:
                from rdagent.components.coder.kronos_adapter import build_kronos_factor, evaluate_kronos_model

                has_cuda = False
                try:
                    import torch
                    has_cuda = torch.cuda.is_available()
                except Exception:
                    pass
                device = "cuda" if has_cuda else "cpu"

                logger.info(f"Kronos-small: generating {factor_name} (pred={pred_bars}, stride=500, {device})...")
                factor_df = build_kronos_factor(
                    hdf5_path=data_path,
                    context_bars=100,
                    pred_bars=pred_bars,
                    stride_bars=500,
                    device=device,
                    batch_size=32,
                    model_size="small",
                )

                values_dir.mkdir(parents=True, exist_ok=True)
                factor_df.to_parquet(parquet_path)

                logger.info(f"Kronos: computing IC for {factor_name}...")
                metrics = evaluate_kronos_model(
                    hdf5_path=data_path,
                    context_bars=100,
                    pred_bars=pred_bars,
                    stride_bars=2000,
                    device=device,
                    batch_size=32,
                    model_size="small",
                )
                ic = metrics.get("IC_mean", 0.0) or 0.0

                factors_dir.mkdir(parents=True, exist_ok=True)
                meta = {
                    "factor_name": factor_name,
                    "status": "success",
                    "ic": ic,
                    "model_size": "small",
                    "model": "NeoQuasar/Kronos-mini",
                    "context_bars": 100,
                    "pred_bars": pred_bars,
                    "device": "cpu",
                    "generated_at": _dt.now().isoformat(),
                }
                json_path.write_text(_json.dumps(meta, indent=2))
                logger.info(f"Kronos: {factor_name} ready — IC={ic:.4f}")

            except Exception as e:
                logger.warning(f"Kronos: {factor_name} failed — {e}")

    async def direct_exp_gen(self, prev_out: dict[str, Any]):
        while True:
            if self.get_unfinished_loop_cnt(self.loop_idx) < RD_AGENT_SETTINGS.get_max_parallel():
                hypo = self._propose()
                if hypo.action not in ["factor", "model"]:
                    raise ValueError(f"hypo.action must be 'factor' or 'model', got {hypo.action!r}")
                if hypo.action == "factor":
                    exp = self.factor_hypothesis2experiment.convert(hypo, self.trace)
                else:
                    exp = self.model_hypothesis2experiment.convert(hypo, self.trace)
                logger.log_object(exp.sub_tasks, tag="experiment generation")
                exp.base_features = self.plan["features"]
                exp.base_feature_codes = self.plan["feature_codes"]
                if exp.based_experiments:
                    exp.based_experiments[-1].base_features = self.plan["features"]
                    exp.based_experiments[-1].base_feature_codes = self.plan["feature_codes"]
                return {"propose": hypo, "exp_gen": exp}
            await asyncio.sleep(1)

    def coding(self, prev_out: dict[str, Any]):
        exp = None
        try:
            direct = prev_out.get("direct_exp_gen")
            if not direct:
                # Loop was reset (LoopResumeError) while this step was already queued.
                # Treat as empty so skip_loop_error skips this iteration cleanly.
                raise FactorEmptyError("direct_exp_gen result missing after loop reset")
            if direct["propose"].action == "factor":
                exp = self.factor_coder.develop(direct["exp_gen"])
            elif direct["propose"].action == "model":
                exp = self.model_coder.develop(direct["exp_gen"])
            logger.log_object(exp, tag="coder result")
        except (FactorEmptyError, ModelEmptyError) as e:
            logger.warning(f"Coding failed with {type(e).__name__}: {e}")
            raise
        except Exception as e:
            logger.error(f"Unexpected coding error: {e}")
            raise
        finally:
            # Always save results, even on partial failure
            if exp is not None:
                self._save_coder_results(exp)

        return exp

    def _save_coder_results(self, exp) -> None:
        """
        Save CoSTEER-generated code and evaluation to results/ directory.

        This ensures we have a record of generated factors even if
        the full Qlib backtest pipeline fails or is skipped.

        Parameters
        ----------
        exp : Experiment
            The experiment with generated code
        """
        import json
        from datetime import datetime

        try:
            project_root = Path(__file__).parent.parent.parent.parent
            results_dir = project_root / "results" / "runs"
            results_dir.mkdir(parents=True, exist_ok=True)

            # Build result summary
            summary = {
                "timestamp": datetime.now().isoformat(),
                "hypothesis": None,
                "factors": [],
                "status": "generated",
            }

            if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
                summary["hypothesis"] = getattr(exp.hypothesis, "hypothesis", None)

            # Extract generated code from sub_workspace_list
            if hasattr(exp, "sub_workspace_list") and exp.sub_workspace_list:
                for i, ws in enumerate(exp.sub_workspace_list):
                    factor_info = {
                        "index": i,
                        "code": None,
                        "file_count": 0,
                    }
                    if hasattr(ws, "file_dict") and ws.file_dict:
                        factor_info["file_count"] = len(ws.file_dict)
                        factor_info["code"] = ws.file_dict.get("factor.py", None)
                    summary["factors"].append(factor_info)

            # Check if experiment was accepted or rejected
            if hasattr(exp, "accepted_tasks"):
                accepted = getattr(exp, "accepted_tasks", [])
                summary["accepted_count"] = len(accepted)
                summary["status"] = "accepted" if accepted else "rejected"

            # Write JSON summary
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            safe_name = (summary["hypothesis"] or "unknown_factor")[:80].replace("/", "_").replace(" ", "_")
            json_path = results_dir / f"{timestamp}_{safe_name}.json"

            with open(json_path, "w", encoding="utf-8") as f:
                json.dump(summary, f, ensure_ascii=False, indent=2, default=str)

            logger.info(f"CoSTEER result saved to {json_path}")

            # Also write a consolidated log entry
            log_dir = project_root / "results" / "logs"
            log_dir.mkdir(parents=True, exist_ok=True)
            today = datetime.now().strftime("%Y-%m-%d")
            log_file = log_dir / f"coder_runs_{today}.jsonl"

            with open(log_file, "a", encoding="utf-8") as f:
                f.write(json.dumps(summary, ensure_ascii=False, default=str) + "\n")

        except Exception as e:
            logger.warning(f"Failed to save CoSTEER results: {e}")

    def running(self, prev_out: dict[str, Any]):
        if prev_out["direct_exp_gen"]["propose"].action == "factor":
            exp = self.factor_runner.develop(prev_out["coding"])
            if exp is None:
                logger.error("Factor extraction failed.")
                raise FactorEmptyError("Factor extraction failed.")

            # Increment factor count for tracking
            if hasattr(self, "trace") and hasattr(self.trace, "increment_factor_count"):
                self.trace.increment_factor_count()

            # Handle failed experiments gracefully (don't break the loop)
            if getattr(exp, "failed", False):
                reason = getattr(exp, "failure_reason", "unknown")
                factor_name = "unknown"
                if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
                    factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
                logger.warning(
                    f"Factor '{factor_name}' failed evaluation: {reason}. "
                    f"Continuing with next factor.",
                )
                # Return exp anyway - loop will continue
        elif prev_out["direct_exp_gen"]["propose"].action == "model":
            exp = self.model_runner.develop(prev_out["coding"])
        logger.log_object(exp, tag="runner result")
        return exp

    def feedback(self, prev_out: dict[str, Any]):
        e = prev_out.get(self.EXCEPTION_KEY)
        if e is not None:
            feedback = HypothesisFeedback(
                observations=str(e),
                hypothesis_evaluation="",
                new_hypothesis="",
                reason="",
                decision=False,
            )
        else:
            # Handle cases where the experiment failed during execution (e.g., Docker error)
            exp = prev_out.get("running")
            if exp is not None and getattr(exp, "failed", False):
                reason = getattr(exp, "failure_reason", "Unknown failure reason")
                factor_name = "unknown"
                if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
                    factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")

                logger.warning(f"Skipping feedback for failed factor '{factor_name}'. Reason: {reason}")
                feedback = HypothesisFeedback(
                    observations=f"Factor '{factor_name}' failed execution.",
                    hypothesis_evaluation="Failed",
                    new_hypothesis="Try a different approach.",
                    reason=reason,
                    decision=False,
                )
            elif prev_out["direct_exp_gen"]["propose"].action == "factor":
                feedback = self.factor_summarizer.generate_feedback(prev_out["running"], self.trace)
            elif prev_out["direct_exp_gen"]["propose"].action == "model":
                feedback = self.model_summarizer.generate_feedback(prev_out["running"], self.trace)

            # NOTE: DB save is handled by factor_runner.py _save_result_to_database()
            # which runs immediately after Docker execution. No duplicate save needed here.

        # Periodically build strategies using AI when enough factors are available
        factor_count = self.trace.get_factor_count()

        # Check for auto-strategies trigger
        auto_strategies = getattr(self, "_auto_strategies", False)
        auto_threshold = getattr(self, "_auto_strategies_threshold", 500)

        if auto_strategies and factor_count > 0 and factor_count % auto_threshold == 0:
            logger.info(
                f"Auto-strategy trigger: {factor_count} factors evaluated. "
                f"Suggesting strategy generation now...",
            )
            self._build_strategies_with_ai()
        elif factor_count > 0 and factor_count % 50 == 0 and not auto_strategies:
            # Standard periodic suggestion (every 50 factors)
            logger.info(
                f"Periodic check: {factor_count} factors evaluated. "
                f"Consider running 'rdagent generate_strategies' for AI strategy generation.",
            )

        feedback = self._interact_feedback(feedback)
        logger.log_object(feedback, tag="feedback")
        return feedback

    def _build_strategies_with_ai(self) -> None:
        """
        Build trading strategies using StrategyOrchestrator with Optuna optimization.

        This method is called periodically during the factor generation loop
        to convert accumulated factors into trading strategies.

        Features:
        - Uses improved LLM prompt (strategy_generation_v2.yaml)
        - Forward-fills daily factors to 1-min OHLCV
        - Realistic backtesting with real OHLCV data
        - Optuna hyperparameter optimization
        """
        try:
            from pathlib import Path

            import yaml
            from rdagent.scenarios.qlib.local.strategy_orchestrator import StrategyOrchestrator

            # Load improved prompt
            project_root = Path(__file__).parent.parent.parent.parent
            prompt_path = project_root / "prompts" / "strategy_generation_v2.yaml"
            if prompt_path.exists():
                with open(prompt_path) as f:
                    improved_prompt = yaml.safe_load(f)
            else:
                improved_prompt = None

            # Load factors from results
            results_dir = project_root / "results"
            factors_dir = results_dir / "factors"

            if not factors_dir.exists():
                logger.debug("StrategyOrchestrator: No factors directory found. Skipping.")
                return

            # Load evaluated factors
            factors = []
            for f in factors_dir.glob("*.json"):
                try:
                    with open(f) as fh:
                        data = json.load(fh)
                    if data.get("status") == "success" and data.get("ic") is not None:
                        factors.append(data)
                except Exception:
                    logger.warning("Failed to load factor file %s", f, exc_info=True)
                    continue

            if len(factors) < 10:
                logger.debug(f"StrategyOrchestrator: Only {len(factors)} factors available. Need at least 10. Skipping.")
                return

            # Sort by IC and take top 50
            factors.sort(key=lambda x: abs(x.get("ic", 0) or 0), reverse=True)
            top_factors = factors[:50]

            logger.info(f"StrategyOrchestrator: Building strategies from {len(top_factors)} top factors...")
            logger.info(f"  - Using improved prompt: {improved_prompt is not None}")
            logger.info("  - Optuna optimization: enabled (20 trials)")
            logger.info("  - Real OHLCV backtest: enabled")

            # Initialize orchestrator with Optuna
            orchestrator = StrategyOrchestrator(
                top_factors=20,
                trading_style="swing",
                min_sharpe=1.5,
                max_drawdown=-0.20,
                min_win_rate=0.40,
                use_optuna=True,
                optuna_trials=20,
            )

            # Override with improved prompt if available
            if improved_prompt:
                orchestrator.strategy_prompt = improved_prompt.get("strategy_generation", {})

            # Generate 3 strategies per cycle
            n_strategies = 3
            logger.info(f"Generating {n_strategies} strategies...")

            # Load top factors for generation
            orch_factors = orchestrator.load_top_factors()
            if len(orch_factors) < 2:
                logger.warning(f"Not enough factors for strategy generation (need >= 2, got {len(orch_factors)}). Skipping.")
                return

            for i in range(n_strategies):
                strategy_name = f"auto_gen_v{i+1}"
                try:
                    # Select random factor combination
                    import random
                    n_factors = random.randint(2, min(5, len(orch_factors)))
                    factor_subset = random.sample(orch_factors, n_factors)

                    code = orchestrator.generate_strategy_code(factor_subset, strategy_name)

                    if code:
                        result = orchestrator.evaluate_strategy(code, strategy_name, factor_subset)

                        if result.get("status") == "accepted":
                            logger.info(f"✅ Strategy {strategy_name} accepted!")
                            logger.info(f"   Sharpe: {result.get('sharpe_ratio', 0):.2f}")
                            logger.info(f"   Max DD: {result.get('max_drawdown', 0):.4f}")
                            logger.info(f"   Win Rate: {result.get('win_rate', 0):.4f}")
                        else:
                            logger.info(f"❌ Strategy {strategy_name} rejected: {result.get('reason', 'unknown')[:100]}")
                except Exception as e:
                    logger.warning(f"Strategy generation failed for {strategy_name}: {e}")

            logger.info("StrategyOrchestrator: Cycle complete.")

        except ImportError as e:
            logger.warning(f"StrategyOrchestrator: Import failed ({e}). Skipping strategy building.")
        except Exception as e:
            # Don't break the main loop for strategy building failures
            logger.warning(f"StrategyOrchestrator: Unexpected error: {e}. Skipping strategy building.")


def main(
    path=None,
    step_n: int | None = None,
    loop_n: int | None = None,
    all_duration: str | None = None,
    checkout: bool = True,
    base_features_path: str | None = None,
    auto_strategies: bool = False,
    auto_strategies_threshold: int = 500,
    **kwargs,
):
    """
    Auto R&D Evolving loop for fintech factors.
    You can continue running session by
    .. code-block:: python
        dotenv run -- python rdagent/app/qlib_rd_loop/quant.py $LOG_PATH/__session__/1/0_propose  --step_n 1   # `step_n` is a optional paramter

    Parameters
    ----------
    auto_strategies : bool
        Automatically generate strategies after factor threshold
    auto_strategies_threshold : int
        Number of factors before triggering strategy generation
    """
    if path is None:
        quant_loop = QuantRDLoop(QUANT_PROP_SETTING)
    else:
        quant_loop = QuantRDLoop.load(path, checkout=checkout)
    quant_loop._init_base_features(base_features_path)
    quant_loop._ensure_kronos_factors_in_pool()
    if "user_interaction_queues" in kwargs and kwargs["user_interaction_queues"] is not None:
        quant_loop._set_interactor(*kwargs["user_interaction_queues"])
        quant_loop._interact_init_params()

    # Store auto_strategies settings for use in feedback loop
    if auto_strategies:
        quant_loop._auto_strategies = True
        quant_loop._auto_strategies_threshold = auto_strategies_threshold
        logger.info(
            f"Auto-strategies enabled. Will trigger after {auto_strategies_threshold} factors.",
        )
    else:
        quant_loop._auto_strategies = False
        quant_loop._auto_strategies_threshold = auto_strategies_threshold

    asyncio.run(quant_loop.run(step_n=step_n, loop_n=loop_n, all_duration=all_duration))


if __name__ == "__main__":
    fire.Fire(main)
