"""Intraday backtest API endpoints (ORB / Morning Momentum)."""
from __future__ import annotations

import calendar
import datetime as dt
import json
import os
import re
import subprocess
import sys
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import yaml
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from apps.web.dependencies import get_project_root, get_runs_dir

router = APIRouter(prefix="/intraday", tags=["intraday"])

# ---------------------------------------------------------------------------
# Task registry — in-memory + disk persistence
# ---------------------------------------------------------------------------
_tasks: dict[str, dict[str, Any]] = {}
_tasks_lock = threading.Lock()
_tasks_initialized = False

_DEFAULT_OUTPUT_DIR_BY_MODE: dict[str, str] = {
    "momentum": "runs/intraday",
    "orb": "runs/intraday_orb",
}

_RUN_VALID_UNIVERSES: set[str] = {
    "sp500",
    "nasdaq100",
    "broad",
    "midlarge",
    "largecap",
    "midcap",
    "smallmid",
    "screener",
    "yaml",
}

_EDITOR_VALID_UNIVERSES: set[str] = {
    "sp500",
    "nasdaq100",
    "broad",
    "midlarge",
    "largecap",
    "midcap",
    "smallmid",
}

# Built-in strategies (read-only presets shipped with the system)
# All strategies are now directory-based (configs/intraday/strategies/).
_BUILTIN_STRATEGIES: dict[str, Any] = {}

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _log_dir() -> Path:
    return get_runs_dir() / ".intraday_tasks"


def _log_file(task_id: str) -> Path:
    return _log_dir() / f"{task_id}.log"


def _task_file(task_id: str) -> Path:
    return _log_dir() / f"{task_id}.task.json"


def _strategies_dir() -> Path:
    return get_project_root() / "configs" / "intraday" / "strategies"



def _persist_task(task: dict[str, Any]) -> None:
    try:
        _log_dir().mkdir(parents=True, exist_ok=True)
        _task_file(task["task_id"]).write_text(json.dumps(task, indent=2))
    except Exception:
        pass


def _is_pid_alive(pid: int) -> bool:
    try:
        os.kill(pid, 0)
        return True
    except ProcessLookupError:
        return False
    except PermissionError:
        return True


_ANSI_ESCAPE = re.compile(r'\x1b\[[0-9;]*[A-Za-z]|\x1b\[[0-9;]*m')


def _strip_ansi(text: str) -> str:
    return _ANSI_ESCAPE.sub('', text)


def _log_tail_error(log_path: Path, lines: int = 50) -> str:
    """Return last N lines of a log file as an error message (ANSI stripped)."""
    try:
        if log_path.exists():
            text = _strip_ansi(log_path.read_text(errors="replace"))
            tail = "\n".join(text.splitlines()[-lines:])
            return tail or "(empty log)"
    except Exception:
        pass
    return "(log unavailable)"


def _detect_result_file(started_at: datetime, output_dir: str | None = None) -> Path | None:
    """Find the most recent intraday result JSON written after started_at."""
    out_dir_name = output_dir or _DEFAULT_OUTPUT_DIR_BY_MODE["orb"]
    out_dir = get_project_root() / out_dir_name
    if not out_dir.exists():
        return None
    candidates = []
    for f in out_dir.glob("intraday_*.json"):
        try:
            mtime = datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc)
            if mtime > started_at:
                candidates.append((mtime, f))
        except Exception:
            pass
    if not candidates:
        return None
    candidates.sort(key=lambda x: x[0], reverse=True)
    return candidates[0][1]


def _result_summary_from_file(result_path: Path) -> dict[str, Any] | None:
    """Parse key metrics from an intraday result JSON file."""
    try:
        data = json.loads(result_path.read_text())
        m = data.get("metrics", {})
        return {
            "return_pct": m.get("total_return_pct"),
            "max_dd_pct": m.get("max_drawdown_pct"),
            "sharpe": m.get("sharpe_ratio"),
            "win_rate": (m.get("win_rate") or 0) * 100 if m.get("win_rate") is not None else None,
            "trade_count": m.get("total_trades"),
            "final_equity": m.get("final_equity"),
            "calmar": m.get("calmar_ratio"),
            "loss_containment_score": m.get("loss_containment_score"),
            "avg_loss_day_pct": m.get("avg_loss_day_pct"),
            "tail_loss_20_pct": m.get("tail_loss_20_pct"),
            "worst_day_return_pct": m.get("worst_day_return_pct"),
        }
    except Exception:
        return None


def _load_strategy_runtime(config_path: str | Path) -> tuple[str, str]:
    """Return (strategy_mode, output_dir) derived from a config file."""
    raw = yaml.safe_load(Path(config_path).read_text()) or {}
    strategy_mode = str(raw.get("strategy_mode") or "momentum").lower()
    if strategy_mode not in _DEFAULT_OUTPUT_DIR_BY_MODE:
        strategy_mode = "momentum"
    output_dir = str(
        raw.get("output", {}).get("dir")
        or _DEFAULT_OUTPUT_DIR_BY_MODE[strategy_mode]
    )
    return strategy_mode, output_dir


def _load_strategy_universe(config_path: str | Path) -> tuple[str, str | None]:
    """Return (universe_source, symbols_file) from a strategy config."""
    raw = yaml.safe_load(Path(config_path).read_text()) or {}
    universe = raw.get("universe", {}) or {}
    source = str(universe.get("source") or "midlarge").lower()
    symbols_file = universe.get("symbols_file")
    return source, str(symbols_file) if symbols_file else None


def _format_universe_label(source: str, symbols_file: str | None) -> str:
    if source == "yaml" and symbols_file:
        return f"yaml:{Path(symbols_file).name}"
    return source


def _watch_process(task_id: str, proc: subprocess.Popen) -> None:  # type: ignore[type-arg]
    """Background thread: wait for subprocess to finish, then update task state."""
    import time

    # Mark as running once PID is known
    with _tasks_lock:
        task = _tasks.get(task_id)
        if task:
            task["status"] = "running"
            task["pid"] = proc.pid
            task["started_at"] = datetime.now(timezone.utc).isoformat()
            _persist_task(task)

    # Wait for subprocess
    proc.wait()

    started_at_str = None
    with _tasks_lock:
        task = _tasks.get(task_id)
        if task:
            started_at_str = task.get("started_at")

    started_at = datetime.now(timezone.utc)
    if started_at_str:
        try:
            started_at = datetime.fromisoformat(started_at_str.replace("Z", "+00:00"))
        except Exception:
            pass

    # Allow a brief moment for file system writes to flush
    time.sleep(1)

    result_path = _detect_result_file(started_at, task.get("output_dir") if task else None)
    log_path = _log_file(task_id)

    with _tasks_lock:
        task = _tasks.get(task_id)
        if task is None:
            return
        if task.get("status") == "cancelled":
            _persist_task(task)
            return

        task["pid"] = None
        task["finished_at"] = datetime.now(timezone.utc).isoformat()
        task["returncode"] = proc.returncode

        if proc.returncode == 0 and result_path:
            task["status"] = "completed"
            task["result_file"] = str(result_path)
            summary = _result_summary_from_file(result_path)
            if summary:
                task["result_summary"] = summary
        else:
            task["status"] = "failed"
            task["error"] = _log_tail_error(log_path)

        _persist_task(task)


def _load_tasks_from_disk() -> None:
    """Restore persisted tasks on startup."""
    ld = _log_dir()
    if not ld.exists():
        return
    for f in sorted(ld.glob("*.task.json")):
        try:
            data = json.loads(f.read_text())
            task_id = data.get("task_id")
            if not task_id or task_id in _tasks:
                continue
            # Tasks stuck in running/queued get resolved
            if data.get("status") in ("running", "queued"):
                pid = data.get("pid")
                if pid and _is_pid_alive(pid):
                    _tasks[task_id] = data
                    continue
                # Process dead — check for result
                data["pid"] = None
                if not data.get("finished_at"):
                    data["finished_at"] = datetime.now(timezone.utc).isoformat()
                started_at_str = data.get("started_at")
                result_path = None
                if started_at_str:
                    try:
                        started_at = datetime.fromisoformat(started_at_str.replace("Z", "+00:00"))
                        result_path = _detect_result_file(started_at, data.get("output_dir"))
                    except Exception:
                        pass
                if result_path:
                    data["status"] = "completed"
                    data["result_file"] = str(result_path)
                    summary = _result_summary_from_file(result_path)
                    if summary:
                        data["result_summary"] = summary
                else:
                    data["status"] = "failed"
                    data["error"] = "Server restarted while task was running"
                f.write_text(json.dumps(data, indent=2))
            _tasks[task_id] = data
        except Exception:
            pass


def _ensure_initialized() -> None:
    global _tasks_initialized
    if not _tasks_initialized:
        _tasks_initialized = True
        _load_tasks_from_disk()


def _slugify(name: str) -> str:
    """Convert a name to a safe slug (lowercase, underscores, alphanumeric)."""
    s = name.lower().strip()
    s = re.sub(r"[^a-z0-9]+", "_", s)
    s = s.strip("_")
    return s or "strategy"


def _next_strategy_id() -> int:
    """Return the next available strategy ID (max existing + 1, starting at 1)."""
    sdir = _strategies_dir()
    used: set[int] = set()
    if sdir.exists():
        for yaml_file in sdir.glob("*.yaml"):
            try:
                raw = yaml.safe_load(yaml_file.read_text()) or {}
                sid = raw.get("_meta", {}).get("id")
                if isinstance(sid, int) and sid > 0:
                    used.add(sid)
            except Exception:
                pass
    if not used:
        return 1
    return max(used) + 1


def _load_user_strategy(slug: str) -> dict[str, Any] | None:
    """Load a user strategy from its YAML file. Returns None if not found."""
    path = _strategies_dir() / f"{slug}.yaml"
    if not path.exists():
        return None
    try:
        raw = yaml.safe_load(path.read_text()) or {}
        meta = raw.get("_meta", {})
        strategy_mode = str(raw.get("strategy_mode") or "momentum").lower()
        orb = raw.get("orb_strategy", {})
        momentum = raw.get("strategy", {})
        backtest = raw.get("backtest", {})
        universe = raw.get("universe", {})
        base = {
            "slug": slug,
            "id": meta.get("id"),
            "name": meta.get("name", slug),
            "description": meta.get("description", ""),
            "builtin": False,
            "config_path": str(path.relative_to(get_project_root())),
            "days": backtest.get("lookback_trading_days", 200),
            "universe": universe.get("source", "midlarge"),
            "universe_symbols_file": universe.get("symbols_file"),
            "universe_label": _format_universe_label(
                str(universe.get("source", "midlarge")),
                str(universe.get("symbols_file")) if universe.get("symbols_file") else None,
            ),
            "strategy_mode": strategy_mode,
            "output_dir": raw.get("output", {}).get(
                "dir",
                _DEFAULT_OUTPUT_DIR_BY_MODE.get(strategy_mode, _DEFAULT_OUTPUT_DIR_BY_MODE["momentum"]),
            ),
        }
        if strategy_mode == "orb":
            base.update({
                "initial_capital": orb.get("initial_capital", 10000.0),
                "risk_per_trade_pct": orb.get("risk_per_trade_pct", 0.0025),
                "max_position_pct": orb.get("max_position_pct", 0.20),
                "atr_stop_multiplier": orb.get("atr_stop_multiplier", 0.50),
                "min_rvol": orb.get("min_rvol", 1.0),
                "max_candidates": orb.get("max_candidates", 20),
                "daily_max_loss_pct": orb.get("daily_max_loss_pct", 0.0125),
                "max_stops_per_day": orb.get("max_stops_per_day", 3),
                "breakeven_at_r": orb.get("breakeven_at_r", 1.0),
                "trailing_at_r": orb.get("trailing_at_r", 2.0),
                "trailing_stop_atr_multiplier": orb.get("trailing_stop_atr_multiplier", 0.0),
                "order_timeout_minutes": orb.get("order_timeout_minutes", 45),
                "settlement_days": orb.get("settlement_days", 1),
                "min_candidate_breadth": orb.get("min_candidate_breadth"),
                "max_gap_pct": orb.get("max_gap_pct", 0.10),
                "sim_bar_minutes": orb.get("sim_bar_minutes", 5),
                "orb_minutes": orb.get("orb_minutes", 5),
                "compound_returns": orb.get("compound_returns", True),
            })
        else:
            base.update({
                "initial_capital": momentum.get("initial_capital", 10000.0),
                "entry_minutes_after_open": momentum.get("entry_minutes_after_open", 30),
                "exit_minutes_before_close": momentum.get("exit_minutes_before_close", 30),
                "top_n": momentum.get("top_n", 3),
                "min_morning_gain_pct": momentum.get("min_morning_gain_pct", 0.01),
                "max_morning_gain_pct": momentum.get("max_morning_gain_pct"),
                "min_entry_volume": momentum.get("min_entry_volume"),
                "stop_loss_pct": momentum.get("stop_loss_pct"),
                "trailing_stop_pct": momentum.get("trailing_stop_pct"),
                "ticker_cooldown_days": momentum.get("ticker_cooldown_days", 0),
                "market_regime_spy_threshold": momentum.get("market_regime_spy_threshold"),
                "compound_returns": False,
            })
        return base
    except Exception:
        return None


def _save_user_strategy(strat_data: dict[str, Any]) -> None:
    """Write a user strategy dict to its YAML file."""
    _strategies_dir().mkdir(parents=True, exist_ok=True)
    slug = strat_data["slug"]
    path = _strategies_dir() / f"{slug}.yaml"

    meta_block: dict[str, Any] = {
        "name": strat_data["name"],
        "description": strat_data.get("description", ""),
    }
    if strat_data.get("id") is not None:
        meta_block["id"] = strat_data["id"]

    data: dict[str, Any] = {
        "_meta": meta_block,
        "strategy_mode": "orb",
        "orb_strategy": {
            "orb_minutes": strat_data.get("orb_minutes", 5),
            "sim_bar_minutes": strat_data.get("sim_bar_minutes", 30),
            "entry_direction": "long_only",
            "order_timeout_minutes": strat_data.get("order_timeout_minutes", 45),
            "min_price": 10.0,
            "min_avg_dollar_volume": 25000000,
            "min_atr_14": 0.50,
            "min_rvol": strat_data.get("min_rvol", 1.0),
            "max_candidates": strat_data.get("max_candidates", 20),
            "min_candidates_to_trade": 3,
            "weight_rvol": 0.60,
            "weight_gap": 0.25,
            "weight_dollar_vol": 0.15,
            "atr_stop_multiplier": strat_data.get("atr_stop_multiplier", 0.50),
            "breakeven_at_r": strat_data.get("breakeven_at_r", 1.0),
            "trailing_at_r": strat_data.get("trailing_at_r", 2.0),
            "trailing_stop_atr_multiplier": strat_data.get("trailing_stop_atr_multiplier", 0.0),
            "risk_per_trade_pct": strat_data.get("risk_per_trade_pct", 0.0025),
            "max_position_pct": strat_data.get("max_position_pct", 0.20),
            "daily_max_loss_pct": strat_data.get("daily_max_loss_pct", 0.0125),
            "max_stops_per_day": strat_data.get("max_stops_per_day", 3),
            "exit_minutes_before_close": 5,
            "slippage_bps": 5.0,
            "initial_capital": strat_data.get("initial_capital", 10000.0),
            "ticker_cooldown_days": 0,
            "market_regime_spy_threshold": None,
            "min_candidate_breadth": strat_data.get("min_candidate_breadth"),
            "settlement_days": strat_data.get("settlement_days", 1),
            "max_gap_pct": strat_data.get("max_gap_pct", 0.10),
            "compound_returns": strat_data.get("compound_returns", False),
        },
        "universe": {
            "source": strat_data.get("universe", "midlarge"),
            "min_price": 10.0,
        },
        "backtest": {
            "start_date": None,
            "end_date": None,
            "lookback_trading_days": strat_data.get("days", 200),
            "pre_screen_threshold": 0.01,
        },
        "cache": {"enabled": True, "dir": "data/cache/intraday"},
        "output": {"dir": "runs/intraday_orb", "verbose": False},
    }
    path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True))


def _resolve_config_path(slug: str) -> str:
    """Resolve a strategy slug to an absolute config YAML path."""
    user_path = _strategies_dir() / f"{slug}.yaml"
    if user_path.exists():
        return str(user_path)
    raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")


def _parse_dates(start: str | None, end: str | None, year: str | None) -> tuple[str | None, str | None]:
    """Normalize date inputs to YYYY-MM-DD strings.

    Accepts: YYYY, YYYY-MM, YYYY-MM-DD for start/end; YYYY for year.
    Returns (start_date, end_date) as ISO strings or None.
    """
    if year:
        y = int(year)
        return f"{y}-01-01", f"{y}-12-31"

    def _expand(s: str, is_end: bool = False) -> str:
        s = s.strip()
        if len(s) == 4 and s.isdigit():
            y = int(s)
            return f"{y}-12-31" if is_end else f"{y}-01-01"
        if len(s) == 7 and s[4] == "-":
            y, m = int(s[:4]), int(s[5:])
            if is_end:
                last = calendar.monthrange(y, m)[1]
                return f"{y}-{m:02d}-{last:02d}"
            return f"{y}-{m:02d}-01"
        # Assume YYYY-MM-DD
        dt.date.fromisoformat(s)  # validate; raises ValueError if bad
        return s

    start_out = _expand(start) if start else None
    end_out = _expand(end, is_end=True) if end else None
    return start_out, end_out


# ---------------------------------------------------------------------------
# Request / Response Models
# ---------------------------------------------------------------------------

class IntradayBacktestRequest(BaseModel):
    config: str = "orb_aggressive"      # strategy slug (built-in or user-defined)
    days: int = 200
    universe: str = "midlarge"
    # Date range (optional — mutually exclusive with `days` when start is set)
    year: str | None = None             # YYYY shorthand (sets start=Jan 1, end=Dec 31)
    start_date: str | None = None       # YYYY, YYYY-MM, or YYYY-MM-DD
    end_date: str | None = None         # YYYY, YYYY-MM, or YYYY-MM-DD (default: today)
    # Per-run overrides
    compound_returns: bool | None = None  # None = use strategy config default
    initial_capital: float | None = None  # None = use strategy config default
    daily_budget_reset: bool | None = None  # Research mode: reset budget daily


class CreateStrategyRequest(BaseModel):
    name: str
    description: str = ""
    # Backtest params
    days: int = 200
    universe: str = "midlarge"
    # ORB strategy params
    initial_capital: float = 10000.0
    risk_per_trade_pct: float = 0.0025
    max_position_pct: float = 0.20
    atr_stop_multiplier: float = 0.50
    min_rvol: float = 1.0
    max_candidates: int = 20
    daily_max_loss_pct: float = 0.0125
    max_stops_per_day: int = 3
    breakeven_at_r: float = 1.0
    trailing_at_r: float = 2.0
    trailing_stop_atr_multiplier: float = 0.0
    order_timeout_minutes: int = 45
    settlement_days: int = 1
    min_candidate_breadth: float | None = None
    max_gap_pct: float | None = 0.10
    sim_bar_minutes: int = 30
    orb_minutes: int = 5
    compound_returns: bool = False


class UpdateStrategyRequest(BaseModel):
    name: str | None = None
    description: str | None = None
    days: int | None = None
    universe: str | None = None
    initial_capital: float | None = None
    risk_per_trade_pct: float | None = None
    max_position_pct: float | None = None
    atr_stop_multiplier: float | None = None
    min_rvol: float | None = None
    max_candidates: int | None = None
    daily_max_loss_pct: float | None = None
    max_stops_per_day: int | None = None
    breakeven_at_r: float | None = None
    trailing_at_r: float | None = None
    trailing_stop_atr_multiplier: float | None = None
    order_timeout_minutes: int | None = None
    settlement_days: int | None = None
    min_candidate_breadth: float | None = None
    max_gap_pct: float | None = None
    sim_bar_minutes: int | None = None
    orb_minutes: int | None = None
    compound_returns: bool | None = None


# ---------------------------------------------------------------------------
# Strategy CRUD Endpoints
# ---------------------------------------------------------------------------

@router.get("/strategies")
def list_strategies() -> dict[str, Any]:
    """List all strategies from disk."""
    strategies: list[dict[str, Any]] = []
    sdir = _strategies_dir()
    if sdir.exists():
        for yaml_file in sorted(sdir.glob("*.yaml")):
            strat = _load_user_strategy(yaml_file.stem)
            if strat:
                strategies.append({k: v for k, v in strat.items() if k != "config_path"})
    return {"strategies": strategies}


@router.get("/strategies/{slug}")
def get_strategy(slug: str) -> dict[str, Any]:
    """Get a single strategy by slug."""
    strat = _load_user_strategy(slug)
    if not strat:
        raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")
    return {k: v for k, v in strat.items() if k != "config_path"}


@router.post("/strategies")
def create_strategy(req: CreateStrategyRequest) -> dict[str, Any]:
    """Create a new user-defined strategy."""
    if req.universe not in _EDITOR_VALID_UNIVERSES:
        raise HTTPException(status_code=400, detail=f"Unknown universe: {req.universe!r}")

    slug = _slugify(req.name)
    if not slug:
        raise HTTPException(status_code=400, detail="Strategy name produces empty slug")

    if (_strategies_dir() / f"{slug}.yaml").exists():
        raise HTTPException(status_code=409, detail=f"Strategy {slug!r} already exists")

    strat_data = {
        "slug": slug,
        "id": _next_strategy_id(),
        "name": req.name,
        "description": req.description,
        "builtin": False,
        "days": req.days,
        "universe": req.universe,
        "universe_label": req.universe,
        "initial_capital": req.initial_capital,
        "risk_per_trade_pct": req.risk_per_trade_pct,
        "max_position_pct": req.max_position_pct,
        "atr_stop_multiplier": req.atr_stop_multiplier,
        "min_rvol": req.min_rvol,
        "max_candidates": req.max_candidates,
        "daily_max_loss_pct": req.daily_max_loss_pct,
        "max_stops_per_day": req.max_stops_per_day,
        "breakeven_at_r": req.breakeven_at_r,
        "trailing_at_r": req.trailing_at_r,
        "trailing_stop_atr_multiplier": req.trailing_stop_atr_multiplier,
        "order_timeout_minutes": req.order_timeout_minutes,
        "settlement_days": req.settlement_days,
        "min_candidate_breadth": req.min_candidate_breadth,
        "max_gap_pct": req.max_gap_pct,
        "sim_bar_minutes": req.sim_bar_minutes,
        "orb_minutes": req.orb_minutes,
        "compound_returns": req.compound_returns,
    }
    _save_user_strategy(strat_data)
    return strat_data


@router.put("/strategies/{slug}")
def update_strategy(slug: str, req: UpdateStrategyRequest) -> dict[str, Any]:
    """Update a strategy."""
    strat = _load_user_strategy(slug)
    if not strat:
        raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")
    if strat.get("strategy_mode") != "orb":
        raise HTTPException(
            status_code=400,
            detail="Web strategy editor currently supports ORB strategies only",
        )

    # Apply non-None updates
    updates = req.model_dump(exclude_none=True)
    for k, v in updates.items():
        strat[k] = v

    if req.universe and req.universe not in _EDITOR_VALID_UNIVERSES:
        raise HTTPException(status_code=400, detail=f"Unknown universe: {req.universe!r}")

    _save_user_strategy(strat)
    return {k: v for k, v in strat.items() if k != "config_path"}


@router.post("/strategies/{slug}/copy")
def copy_strategy(slug: str) -> dict[str, Any]:
    """Create a copy of an existing strategy with a new slug and ID."""
    strat = _load_user_strategy(slug)
    if not strat:
        raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")
    if strat.get("strategy_mode") != "orb":
        raise HTTPException(
            status_code=400,
            detail="Web strategy copy currently supports ORB strategies only",
        )

    new_name = f"{strat['name']} (copy)"
    base_slug = _slugify(new_name)
    new_slug = base_slug
    counter = 2
    while (_strategies_dir() / f"{new_slug}.yaml").exists():
        new_slug = f"{base_slug}_{counter}"
        counter += 1

    new_strat = {k: v for k, v in strat.items() if k not in ("config_path", "builtin")}
    new_strat["slug"] = new_slug
    new_strat["name"] = new_name
    new_strat["id"] = _next_strategy_id()
    new_strat["builtin"] = False
    _save_user_strategy(new_strat)
    return {k: v for k, v in new_strat.items() if k != "config_path"}


@router.delete("/strategies/{slug}")
def delete_strategy(slug: str) -> dict[str, Any]:
    """Delete a strategy."""
    path = _strategies_dir() / f"{slug}.yaml"
    if not path.exists():
        raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")

    path.unlink()
    return {"slug": slug, "deleted": True}


# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------

@router.post("/backtest/submit")
def submit_intraday_backtest(req: IntradayBacktestRequest) -> dict[str, Any]:
    _ensure_initialized()

    # Resolve config slug → YAML path
    config_path = _resolve_config_path(req.config)

    if req.universe not in _RUN_VALID_UNIVERSES:
        raise HTTPException(status_code=400, detail=f"Unknown universe: {req.universe!r}")

    # Normalize dates
    try:
        start_iso, end_iso = _parse_dates(req.start_date, req.end_date, req.year)
    except (ValueError, TypeError) as exc:
        raise HTTPException(status_code=400, detail=f"Invalid date: {exc}")

    task_id = str(uuid.uuid4())
    task: dict[str, Any] = {
        "task_id": task_id,
        "config": req.config,
        "days": req.days,
        "universe": req.universe,
        "year": req.year,
        "start_date": start_iso,
        "end_date": end_iso,
        "compound_returns": req.compound_returns,
        "daily_budget_reset": req.daily_budget_reset,
        "status": "queued",
        "created_at": datetime.now(timezone.utc).isoformat(),
        "started_at": None,
        "finished_at": None,
        "pid": None,
        "returncode": None,
        "error": None,
        "result_file": None,
        "result_summary": None,
    }

    project_root = get_project_root()
    _log_dir().mkdir(parents=True, exist_ok=True)
    log_path = _log_file(task_id)

    strategy_mode, output_dir_name = _load_strategy_runtime(config_path)
    _strategy_universe_source, strategy_universe_symbols_file = _load_strategy_universe(config_path)
    task["strategy_mode"] = strategy_mode
    task["output_dir"] = output_dir_name
    task["universe_label"] = _format_universe_label(
        req.universe,
        strategy_universe_symbols_file if req.universe == "yaml" else None,
    )
    output_dir = str(project_root / output_dir_name)
    cmd = [
        sys.executable, "-u", "-m", "apps.intraday_bt.run",
        "--strategy", strategy_mode,
        "--config", config_path,
        "--output-dir", output_dir,
    ]
    if req.universe != "yaml":
        cmd.extend(["--universe", req.universe])
    # Date params: explicit range takes precedence over lookback days
    if start_iso:
        cmd.extend(["--start", start_iso])
        if end_iso:
            cmd.extend(["--end", end_iso])
    else:
        cmd.extend(["--days", str(req.days)])

    # Per-run initial capital override
    if req.initial_capital is not None:
        cmd.extend(["--initial-capital", str(req.initial_capital)])

    # Per-run compound/simple override
    if req.compound_returns is True:
        cmd.append("--compound-returns")
    elif req.compound_returns is False:
        cmd.append("--no-compound-returns")

    # Per-run daily budget reset override (research mode)
    if req.daily_budget_reset is True:
        cmd.append("--daily-budget-reset")
    elif req.daily_budget_reset is False:
        cmd.append("--no-daily-budget-reset")

    with _tasks_lock:
        _tasks[task_id] = task
        _persist_task(task)

    with open(log_path, "wb") as log_fp:
        proc = subprocess.Popen(
            cmd,
            stdout=log_fp,
            stderr=subprocess.STDOUT,
            cwd=str(project_root),
        )

    thread = threading.Thread(
        target=_watch_process,
        args=(task_id, proc),
        daemon=True,
    )
    thread.start()

    return {"task_id": task_id, "status": "queued"}


def _try_resolve_dead_task(task_id: str) -> None:
    """If a task is stuck 'running' with a dead PID, auto-resolve its status.

    Called on task GET / list so the UI never shows stale 'running' after the
    subprocess has already exited (handles daemon-thread death on server restart,
    or rare cases where _watch_process didn't persist the final state).
    """
    with _tasks_lock:
        task = _tasks.get(task_id)
        if task is None or task.get("status") != "running":
            return
        pid = task.get("pid")
        if pid and _is_pid_alive(pid):
            return  # genuinely still running

        # Process is gone — detect result file and resolve
        started_at_str = task.get("started_at")
        started_at = datetime.now(timezone.utc)
        if started_at_str:
            try:
                started_at = datetime.fromisoformat(started_at_str.replace("Z", "+00:00"))
            except Exception:
                pass

        result_path = _detect_result_file(started_at, task.get("output_dir"))
        task["pid"] = None
        if not task.get("finished_at"):
            task["finished_at"] = datetime.now(timezone.utc).isoformat()

        if result_path:
            task["status"] = "completed"
            task["result_file"] = str(result_path)
            summary = _result_summary_from_file(result_path)
            if summary:
                task["result_summary"] = summary
        else:
            task["status"] = "failed"
            log_path = _log_file(task_id)
            task["error"] = _log_tail_error(log_path)

        _persist_task(task)


@router.get("/backtest/tasks")
def list_intraday_tasks() -> dict[str, Any]:
    _ensure_initialized()
    with _tasks_lock:
        task_ids = list(_tasks.keys())
    for tid in task_ids:
        _try_resolve_dead_task(tid)
    with _tasks_lock:
        tasks = list(_tasks.values())
    tasks.sort(key=lambda t: t.get("created_at", ""), reverse=True)
    return {"tasks": tasks}


@router.get("/backtest/tasks/{task_id}")
def get_intraday_task(task_id: str) -> dict[str, Any]:
    _ensure_initialized()
    _try_resolve_dead_task(task_id)
    with _tasks_lock:
        task = _tasks.get(task_id)
    if task is None:
        raise HTTPException(status_code=404, detail="Task not found")
    return task


@router.delete("/backtest/tasks/{task_id}")
def cancel_intraday_task(task_id: str) -> dict[str, Any]:
    _ensure_initialized()
    with _tasks_lock:
        task = _tasks.get(task_id)
        if task is None:
            raise HTTPException(status_code=404, detail="Task not found")
        pid = task.get("pid")
        if task["status"] in ("queued", "running"):
            task["status"] = "cancelled"
            task["finished_at"] = datetime.now(timezone.utc).isoformat()
            _persist_task(task)

    if pid:
        try:
            import signal as _sig
            os.kill(pid, _sig.SIGTERM)
        except Exception:
            pass

    return {"task_id": task_id, "status": "cancelled"}


@router.delete("/backtest/tasks/{task_id}/delete")
def delete_intraday_task(task_id: str) -> dict[str, Any]:
    """Permanently remove a task from history (cancel first if still running)."""
    _ensure_initialized()
    with _tasks_lock:
        task = _tasks.get(task_id)
        if task is None:
            raise HTTPException(status_code=404, detail="Task not found")
        pid = task.get("pid")
        if task["status"] in ("queued", "running"):
            task["status"] = "cancelled"
            task["finished_at"] = datetime.now(timezone.utc).isoformat()
        _tasks.pop(task_id, None)

    if pid:
        try:
            import signal as _sig
            os.kill(pid, _sig.SIGTERM)
        except Exception:
            pass

    # Remove persisted files
    try:
        _task_file(task_id).unlink(missing_ok=True)
        _log_file(task_id).unlink(missing_ok=True)
    except Exception:
        pass

    return {"task_id": task_id, "deleted": True}


@router.get("/backtest/tasks/{task_id}/log")
def get_intraday_task_log(task_id: str) -> dict[str, Any]:
    _ensure_initialized()
    with _tasks_lock:
        task = _tasks.get(task_id)
    if task is None:
        raise HTTPException(status_code=404, detail="Task not found")

    log_path = _log_file(task_id)
    try:
        if log_path.exists():
            _ANSI_RE = re.compile(r'\x1b\[[0-9;]*[mGKHABCDFrsu]|\x1b[()][AB012]')
            text = _ANSI_RE.sub("", log_path.read_text(errors="replace"))
            return {"log": text}
    except Exception:
        pass
    return {"log": ""}


@router.get("/backtest/tasks/{task_id}/result")
def get_intraday_task_result(task_id: str) -> dict[str, Any]:
    _ensure_initialized()
    with _tasks_lock:
        task = _tasks.get(task_id)
    if task is None:
        raise HTTPException(status_code=404, detail="Task not found")
    if task["status"] != "completed":
        raise HTTPException(status_code=400, detail="Task not completed")

    result_file = task.get("result_file")
    if not result_file or not Path(result_file).exists():
        raise HTTPException(status_code=404, detail="Result file not found")

    try:
        data = json.loads(Path(result_file).read_text())
        return data
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Failed to parse result: {e}")
