import json
import logging
import os
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple

from app.v1.services.gpt_engine import get_openai_client

logger = logging.getLogger(__name__)

EARLY_MOVERS_GPT_MODE = (os.getenv("EARLY_MOVERS_GPT_MODE", "off") or "off").strip().lower()
def _pick_model() -> str:
    # Prefer a stable chat model for this feature.
    # In this repo, `CHAT_MODEL` is often the most reliable configured value.
    for k in ("EARLY_MOVERS_GPT_MODEL", "CHAT_MODEL", "OPENAI_CHAT_MODEL", "OPENAI_MODEL"):
        v = (os.getenv(k, "") or "").strip()
        if v:
            return v
    return ""


EARLY_MOVERS_GPT_MODEL = _pick_model()
EARLY_MOVERS_GPT_TEMPERATURE = float(os.getenv("EARLY_MOVERS_GPT_TEMPERATURE", "0.0"))
EARLY_MOVERS_GPT_MAX_TOKENS = int(os.getenv("EARLY_MOVERS_GPT_MAX_TOKENS", "1200"))
EARLY_MOVERS_GPT_TIMEOUT = int(os.getenv("EARLY_MOVERS_GPT_TIMEOUT", "45"))

# Safety cap: prevent runaway retries if the model keeps failing.
EARLY_MOVERS_GPT_MAX_CALLS = int(os.getenv("EARLY_MOVERS_GPT_MAX_CALLS", "10"))

# Limits: how many symbols per side are sent to GPT
EARLY_MOVERS_GPT_LIMIT_PER_SIDE = int(os.getenv("EARLY_MOVERS_GPT_LIMIT_PER_SIDE", "25"))


def _max_tokens_kwarg(model: str, max_tokens: int) -> Dict[str, Any]:
    m = (model or "").strip().lower()
    if m.startswith("gpt-5"):
        return {"max_completion_tokens": int(max_tokens)}
    return {"max_tokens": int(max_tokens)}


def _extract_json(text: str) -> Optional[Any]:
    if not text:
        return None
    s = text.strip()

    # Strip common markdown code fences (```json ... ```)
    if s.startswith("```"):
        nl = s.find("\n")
        if nl != -1:
            s2 = s[nl + 1 :]
            end = s2.rfind("```")
            if end != -1:
                s = s2[:end].strip()
    # If the model wraps JSON in text, best-effort extract
    start_obj = s.find("{")
    start_arr = s.find("[")

    if start_arr != -1 and (start_obj == -1 or start_arr < start_obj):
        end = s.rfind("]")
        if end != -1 and end > start_arr:
            chunk = s[start_arr : end + 1]
            try:
                return json.loads(chunk)
            except Exception:
                return None

    if start_obj != -1:
        end = s.rfind("}")
        if end != -1 and end > start_obj:
            chunk = s[start_obj : end + 1]
            try:
                return json.loads(chunk)
            except Exception:
                return None

    try:
        return json.loads(s)
    except Exception:
        return None


def _validate_item(item: Any) -> Optional[Dict[str, Any]]:
    if not isinstance(item, dict):
        return None

    symbol = (item.get("symbol") or "").strip().upper()
    bias = (item.get("bias") or "").strip().upper()
    if not symbol or bias not in ("BULLISH", "BEARISH"):
        return None

    conf = item.get("confidence")
    try:
        conf_f = float(conf)
    except Exception:
        conf_f = None

    if conf_f is None:
        return None

    conf_f = max(0.0, min(100.0, conf_f))

    bias_ok_raw = item.get("bias_ok")
    bias_ok = True
    if isinstance(bias_ok_raw, bool):
        bias_ok = bool(bias_ok_raw)
    elif bias_ok_raw is not None:
        s = str(bias_ok_raw).strip().lower()
        if s in ("false", "0", "no"):
            bias_ok = False
        elif s in ("true", "1", "yes"):
            bias_ok = True

    setup_quality_raw = item.get("setup_quality")
    setup_quality = (str(setup_quality_raw).strip().upper() if setup_quality_raw is not None else "")
    if setup_quality not in ("BAD", "OKAY", "GOOD"):
        # Backward-compatible default if model omits this.
        if conf_f >= 75:
            setup_quality = "GOOD"
        elif conf_f >= 55:
            setup_quality = "OKAY"
        else:
            setup_quality = "BAD"

    risk_flags: List[str] = []
    rf = item.get("risk_flags")
    if isinstance(rf, list):
        for x in rf:
            if isinstance(x, str) and x.strip():
                risk_flags.append(x.strip().upper())

    notes = item.get("notes")
    notes_s = str(notes) if notes is not None else ""

    return {
        "symbol": symbol,
        "bias": bias,
        "confidence": float(conf_f),
        "bias_ok": bool(bias_ok),
        "setup_quality": setup_quality,
        "risk_flags": risk_flags,
        "notes": notes_s[:1200],
    }


def _build_prompt(*, market: Dict[str, Any], candidates: List[Dict[str, Any]]) -> str:
    """Single-call GPT prompt for multiple candidates.

    Candidates must already be compact and features-only.
    """

    items_contract = {
        "type": "array",
        "items": {
            "type": "object",
            "required": ["symbol", "bias", "confidence", "bias_ok", "setup_quality", "risk_flags", "notes"],
            "properties": {
                "symbol": {"type": "string"},
                "bias": {"type": "string", "enum": ["BULLISH", "BEARISH"]},
                "confidence": {"type": "number", "minimum": 0, "maximum": 100},
                "bias_ok": {"type": "boolean"},
                "setup_quality": {"type": "string", "enum": ["BAD", "OKAY", "GOOD"]},
                "risk_flags": {"type": "array", "items": {"type": "string"}},
                "notes": {"type": "string"},
            },
        },
    }

    # Wrap array in an object so we can request response_format=json_object
    contract = {
        "type": "object",
        "required": ["items"],
        "properties": {
            "items": items_contract,
        },
    }

    # Help the model avoid "partial output" by making the requirement explicit.
    candidate_keys = []
    for c in candidates or []:
        if isinstance(c, dict):
            s = (c.get("symbol") or "").strip().upper()
            b = (c.get("bias") or "").strip().upper()
            if s and b:
                candidate_keys.append({"symbol": s, "bias": b})

    payload = {
        "market": market,
        "candidates": candidates,
        "expected": {
            "count": int(len(candidates or [])),
            "keys": candidate_keys,
        },
        "instructions": {
            "task": "Evaluate each candidate for early breakout/breakdown watchlist quality.",
            "output": (
                "Return ONLY ONE valid JSON OBJECT with a single key 'items' (no markdown). "
                "items must be a JSON array matching the schema contract. "
                "You MUST return exactly one item per input candidate (expected.count). "
                "Do not omit any symbol. Do not add extra symbols."
            ),
            "confidence": "0-100, higher means higher probability of follow-through in next 1-5 sessions.",
            "bias_ok": "true if the provided bias is supported by the provided features; otherwise false.",
            "setup_quality": "BAD/OKAY/GOOD based on structure + context; do not use any outside knowledge.",
            "risk_flags": "Use short upper snake-case tags.",
            "strict": "Do not infer raw OHLC/candlestick details unless provided as explicit features/flags.",
        },
        "output_schema": contract,
    }

    return json.dumps(payload, ensure_ascii=False)


def gpt_evaluate_candidates(*, market: Dict[str, Any], candidates: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[str]]:
    """Evaluate candidates via GPT in a single call.

    Returns: (validated_items, errors)
    """
    errors: List[str] = []

    if EARLY_MOVERS_GPT_MODE == "off":
        return [], []

    model = EARLY_MOVERS_GPT_MODEL
    if not model:
        errors.append("GPT model not configured")
        return [], errors

    try:
        client = get_openai_client()
    except Exception as e:
        errors.append(f"OpenAI unavailable: {str(e)}")
        return [], errors

    def _candidate_key_list(rows: List[Dict[str, Any]]) -> List[Tuple[str, str]]:
        ks: List[Tuple[str, str]] = []
        for c in rows or []:
            if not isinstance(c, dict):
                continue
            s = (c.get("symbol") or "").strip().upper()
            b = (c.get("bias") or "").strip().upper()
            if s and b:
                ks.append((s, b))
        return ks

    def _call_once(batch: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[str]]:
        local_errors: List[str] = []
        prompt = _build_prompt(market=market, candidates=batch)
        messages = [
            {
                "role": "system",
                "content": (
                    "You are a disciplined swing trader and risk manager. "
                    "You only use the provided structured data. "
                    "Output ONLY valid JSON. No markdown. No commentary."
                ),
            },
            {"role": "user", "content": prompt},
        ]

        content = ""
        try:
            # Prefer json_object so the model can't emit stray text.
            try:
                resp = client.chat.completions.create(
                    model=model,
                    messages=messages,
                    temperature=EARLY_MOVERS_GPT_TEMPERATURE,
                    timeout=EARLY_MOVERS_GPT_TIMEOUT,
                    response_format={"type": "json_object"},
                    **_max_tokens_kwarg(model, EARLY_MOVERS_GPT_MAX_TOKENS),
                )
            except Exception:
                resp = client.chat.completions.create(
                    model=model,
                    messages=messages,
                    temperature=EARLY_MOVERS_GPT_TEMPERATURE,
                    timeout=EARLY_MOVERS_GPT_TIMEOUT,
                    **_max_tokens_kwarg(model, EARLY_MOVERS_GPT_MAX_TOKENS),
                )
            content = (resp.choices[0].message.content or "").strip()
        except Exception as e:
            local_errors.append(f"OpenAI call failed: {str(e)}")
            return [], local_errors

        raw = _extract_json(content)
        if isinstance(raw, dict):
            # Preferred wrapper: {"items": [...]}
            v = raw.get("items")
            if isinstance(v, list):
                raw = v

        # Backward compatibility (if model ignored wrapper)
        if isinstance(raw, dict):
            for k in ("results", "output", "data"):
                v = raw.get(k)
                if isinstance(v, list):
                    raw = v
                    break

        if isinstance(raw, dict) and raw.get("symbol") and raw.get("bias"):
            raw = [raw]

        if not isinstance(raw, list):
            head = content[:400].replace("\n", " ")
            local_errors.append("GPT response not a JSON items list")
            local_errors.append(f"raw_head={head}")
            return [], local_errors

        out: List[Dict[str, Any]] = []
        for it in raw:
            v = _validate_item(it)
            if v:
                out.append(v)

        if not out:
            local_errors.append("GPT returned no valid items")
        return out, local_errors

    # Coverage-complete evaluation: retry missing candidates in smaller batches.
    wanted_keys = _candidate_key_list(candidates)
    wanted_set = set(wanted_keys)
    results_by_key: Dict[Tuple[str, str], Dict[str, Any]] = {}

    calls = 0
    queue: List[List[Dict[str, Any]]] = [list(candidates or [])]
    while queue and calls < max(1, int(EARLY_MOVERS_GPT_MAX_CALLS)):
        batch = queue.pop(0)
        if not batch:
            continue

        # Skip if everything in this batch is already satisfied
        batch_keys = _candidate_key_list(batch)
        if batch_keys and all((k in results_by_key) for k in batch_keys):
            continue

        calls += 1
        items, errs = _call_once(batch)
        if errs:
            errors.extend(errs)

        for it in items:
            k = ((it.get("symbol") or "").strip().upper(), (it.get("bias") or "").strip().upper())
            if k[0] and k[1]:
                results_by_key[k] = it

        # Recompute missing for this batch
        missing: List[Dict[str, Any]] = []
        for c in batch:
            if not isinstance(c, dict):
                continue
            k = ((c.get("symbol") or "").strip().upper(), (c.get("bias") or "").strip().upper())
            if k in wanted_set and k not in results_by_key:
                missing.append(c)

        if missing:
            # If model returns partial output, smaller batches increase reliability.
            if len(missing) <= 3:
                for m in missing:
                    queue.append([m])
            else:
                mid = max(1, len(missing) // 2)
                queue.append(missing[:mid])
                queue.append(missing[mid:])

    # Emit final list in the same order as input candidates.
    out: List[Dict[str, Any]] = []
    for k in wanted_keys:
        v = results_by_key.get(k)
        if v:
            out.append(v)

    if wanted_keys and len(out) != len(wanted_keys):
        errors.append(f"GPT returned {len(out)} of {len(wanted_keys)} items")
        errors.append(f"gpt_calls={calls}")

    return out, errors


def gpt_limit_per_side() -> int:
    return max(0, int(EARLY_MOVERS_GPT_LIMIT_PER_SIDE))


def gpt_meta() -> Dict[str, Any]:
    return {
        "mode": EARLY_MOVERS_GPT_MODE,
        "model": EARLY_MOVERS_GPT_MODEL,
        "temperature": EARLY_MOVERS_GPT_TEMPERATURE,
        "max_tokens": EARLY_MOVERS_GPT_MAX_TOKENS,
        "timeout": EARLY_MOVERS_GPT_TIMEOUT,
        "limit_per_side": gpt_limit_per_side(),
        "generated_at": datetime.utcnow(),
    }
    