"""Dashboard-managed recurring agent schedules."""

from __future__ import annotations

import logging
import uuid
from datetime import UTC, datetime
from typing import Any

from fastapi import HTTPException
from pydantic import BaseModel, Field, field_validator

from ..utils.thread_ops import langgraph_client
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
from .profiles import get_profile, get_valid_access_token
from .repo_access import repo_config_for_user, require_repo_access_for_user
from .thread_api import _agent_version_metadata, _now_ms, _resolve_run_email

logger = logging.getLogger(__name__)

SCHEDULES_NAMESPACE: list[str] = ["agent_schedules"]
_AGENT_ASSISTANT_ID = "agent"
_SCHEDULER_ASSISTANT_ID = "scheduler"
_CRON_FIELD_RANGES = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))


class ScheduleCreateBody(BaseModel):
    prompt: str = Field(min_length=1, max_length=20_000)
    schedule: str = Field(min_length=1, max_length=120)
    name: str | None = Field(default=None, max_length=120)
    repo: str | None = None
    model_id: str | None = None
    effort: str | None = None

    @field_validator("schedule")
    @classmethod
    def _valid_schedule(cls, value: str) -> str:
        return normalize_cron_schedule(value)


class ScheduleUpdateBody(BaseModel):
    prompt: str | None = Field(default=None, min_length=1, max_length=20_000)
    schedule: str | None = Field(default=None, min_length=1, max_length=120)
    name: str | None = Field(default=None, max_length=120)
    repo: str | None = None
    model_id: str | None = None
    effort: str | None = None
    enabled: bool | None = None

    @field_validator("schedule")
    @classmethod
    def _valid_schedule(cls, value: str | None) -> str | None:
        return normalize_cron_schedule(value) if value is not None else None


def _client():
    return langgraph_client()


def _now_iso() -> str:
    return datetime.now(UTC).isoformat()


def _normalize_model_choice(
    model_id: str | None, effort: str | None
) -> tuple[str | None, str | None]:
    if not isinstance(model_id, str) or model_id not in SUPPORTED_MODEL_IDS:
        return None, None
    if not isinstance(effort, str) or not model_supports_effort(model_id, effort):
        return None, None
    return model_id, effort


def _validate_cron_value(value: str, low: int, high: int) -> None:
    try:
        n = int(value)
    except ValueError as exc:
        raise ValueError("cron fields must use numbers, *, ranges, steps, or lists") from exc
    if n < low or n > high:
        raise ValueError(f"cron value {n} outside allowed range {low}-{high}")


def _validate_cron_field(field: str, low: int, high: int) -> None:
    for segment in field.split(","):
        if not segment:
            raise ValueError("cron fields cannot contain empty list segments")
        base, sep, step = segment.partition("/")
        if sep:
            _validate_cron_value(step, 1, high)
        if base == "*":
            continue
        start, dash, end = base.partition("-")
        if dash:
            _validate_cron_value(start, low, high)
            _validate_cron_value(end, low, high)
            if int(start) > int(end):
                raise ValueError("cron ranges must be ascending")
        else:
            _validate_cron_value(base, low, high)


def normalize_cron_schedule(raw: str) -> str:
    value = " ".join(raw.strip().split())
    parts = value.split(" ")
    if len(parts) != 5:
        raise ValueError("schedule must be a five-field cron expression")
    for part, (low, high) in zip(parts, _CRON_FIELD_RANGES, strict=True):
        _validate_cron_field(part, low, high)
    return value


def _derive_name(prompt: str) -> str:
    return prompt.strip().splitlines()[0][:80] or "Scheduled agent"


def _repo_full_name(repo: dict[str, str] | None) -> str | None:
    if not repo:
        return None
    owner = repo.get("owner")
    name = repo.get("name")
    return f"{owner}/{name}" if owner and name else None


def _schedule_summary(record: dict[str, Any]) -> dict[str, Any]:
    repo = record.get("repo") if isinstance(record.get("repo"), dict) else None
    return {
        "id": record.get("id"),
        "name": record.get("name"),
        "prompt": record.get("prompt"),
        "schedule": record.get("schedule"),
        "repo": _repo_full_name(repo),
        "model": record.get("model"),
        "effort": record.get("effort"),
        "enabled": bool(record.get("enabled")),
        "cronId": record.get("cron_id"),
        "lastThreadId": record.get("last_thread_id"),
        "lastRunId": record.get("last_run_id"),
        "lastTriggeredAt": record.get("last_triggered_at"),
        "lastError": record.get("last_error"),
        "lastErrorAt": record.get("last_error_at"),
        "createdAt": record.get("created_at"),
        "updatedAt": record.get("updated_at"),
    }


async def _get_value(schedule_id: str) -> dict[str, Any] | None:
    item = await _client().store.get_item(SCHEDULES_NAMESPACE, schedule_id)
    if item is None:
        return None
    value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
    return value if isinstance(value, dict) else None


async def _put_value(record: dict[str, Any]) -> dict[str, Any]:
    record = {**record, "updated_at": _now_iso()}
    await _client().store.put_item(SCHEDULES_NAMESPACE, record["id"], record)
    return record


async def get_agent_schedule(schedule_id: str) -> dict[str, Any] | None:
    return await _get_value(schedule_id)


def _user_owns_schedule(record: dict[str, Any], login: str, email: str | None = None) -> bool:
    if record.get("created_by") == login:
        return True
    record_email = record.get("user_email")
    return bool(email and isinstance(record_email, str) and record_email == email.strip().lower())


def _assert_schedule_owner(
    record: dict[str, Any] | None, login: str, email: str | None = None
) -> None:
    if not record or not _user_owns_schedule(record, login, email):
        raise HTTPException(404, "schedule not found")


async def _search_schedule_values(filter: dict[str, Any]) -> list[dict[str, Any]]:
    records: list[dict[str, Any]] = []
    limit = 100
    offset = 0
    while True:
        result = await _client().store.search_items(
            SCHEDULES_NAMESPACE,
            filter=filter,
            limit=limit,
            offset=offset,
        )
        items = result.get("items") if isinstance(result, dict) else getattr(result, "items", [])
        if not items:
            break
        for item in items:
            value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
            if isinstance(value, dict):
                records.append(value)
        if len(items) < limit:
            break
        offset += len(items)
    return records


async def list_agent_schedules(login: str, *, email: str | None = None) -> list[dict[str, Any]]:
    searches: list[dict[str, Any]] = [{"created_by": login}]
    if email and email.strip():
        searches.append({"user_email": email.strip().lower()})

    seen: dict[str, dict[str, Any]] = {}
    for filter in searches:
        for record in await _search_schedule_values(filter):
            schedule_id = record.get("id")
            if isinstance(schedule_id, str) and _user_owns_schedule(record, login, email):
                seen[schedule_id] = record
    records = list(seen.values())
    records.sort(key=lambda item: item.get("updated_at", ""), reverse=True)
    return [_schedule_summary(record) for record in records]


async def _ensure_dashboard_github_token(login: str) -> None:
    token = await get_valid_access_token(login)
    if not token:
        raise HTTPException(401, "github token unavailable, re-login required")


def _build_cron_config(record: dict[str, Any]) -> dict[str, Any]:
    return {
        "configurable": {
            "schedule_id": record["id"],
        },
        "metadata": _agent_version_metadata(),
    }


async def _create_cron(record: dict[str, Any]) -> str:
    cron = await _client().crons.create(
        _SCHEDULER_ASSISTANT_ID,
        schedule=record["schedule"],
        input={"schedule_id": record["id"]},
        config=_build_cron_config(record),
        metadata={
            "kind": "agent_schedule",
            "schedule_id": record["id"],
            "github_login": record.get("created_by"),
        },
    )
    cron_id = cron.get("cron_id") if isinstance(cron, dict) else getattr(cron, "cron_id", None)
    if not isinstance(cron_id, str) or not cron_id:
        raise RuntimeError("cron creation did not return a cron_id")
    return cron_id


async def _delete_cron(cron_id: str | None) -> None:
    if not cron_id:
        return
    try:
        await _client().crons.delete(cron_id)
    except Exception:
        logger.debug("Could not delete schedule cron %s", cron_id, exc_info=True)


async def create_agent_schedule(
    login: str, body: ScheduleCreateBody, *, email: str | None = None
) -> dict[str, Any]:
    await _ensure_dashboard_github_token(login)
    profile = await get_profile(login) or {}
    chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort)
    repo = await repo_config_for_user(login, body.repo)
    schedule_id = str(uuid.uuid4())
    now = _now_iso()
    record: dict[str, Any] = {
        "id": schedule_id,
        "name": (body.name or _derive_name(body.prompt)).strip(),
        "prompt": body.prompt.strip(),
        "schedule": body.schedule,
        "repo": repo,
        "model": chosen_model or profile.get("default_model") or "Default",
        "effort": chosen_effort or profile.get("reasoning_effort"),
        "base_branch": profile.get("base_branch") or "main",
        "branch_prefix": profile.get("branch_prefix"),
        "enabled": True,
        "cron_id": None,
        "last_thread_id": None,
        "last_run_id": None,
        "last_triggered_at": None,
        "last_error": None,
        "last_error_at": None,
        "created_by": login,
        "user_email": (await _resolve_run_email(login, profile) or email or "").strip().lower(),
        "created_at": now,
        "updated_at": now,
    }
    await _put_value(record)
    try:
        cron_id = await _create_cron(record)
    except Exception as exc:
        await _client().store.delete_item(SCHEDULES_NAMESPACE, schedule_id)
        logger.exception("Failed to create schedule cron for %s", schedule_id)
        raise HTTPException(502, "failed to create schedule cron") from exc
    record = await _put_value({**record, "cron_id": cron_id})
    return _schedule_summary(record)


async def update_agent_schedule(
    schedule_id: str, login: str, body: ScheduleUpdateBody, *, email: str | None = None
) -> dict[str, Any]:
    existing = await get_agent_schedule(schedule_id)
    _assert_schedule_owner(existing, login, email)
    assert existing is not None

    patch: dict[str, Any] = {}
    if body.prompt is not None:
        patch["prompt"] = body.prompt.strip()
    if body.schedule is not None:
        patch["schedule"] = body.schedule
    if body.name is not None:
        patch["name"] = body.name.strip() or _derive_name(patch.get("prompt", existing["prompt"]))
    if body.repo is not None:
        patch["repo"] = await repo_config_for_user(login, body.repo)
    if body.model_id is not None or body.effort is not None:
        model, effort = _normalize_model_choice(body.model_id, body.effort)
        if model and effort:
            patch["model"] = model
            patch["effort"] = effort
    if body.enabled is not None:
        patch["enabled"] = body.enabled

    updated = {**existing, **patch}
    schedule_changed = updated.get("schedule") != existing.get("schedule")
    enabled_changed = updated.get("enabled") != existing.get("enabled")
    needs_new_cron = bool(updated.get("enabled")) and (schedule_changed or enabled_changed)

    if needs_new_cron:
        try:
            new_cron_id = await _create_cron(updated)
        except Exception as exc:
            logger.exception("Failed to recreate schedule cron for %s", schedule_id)
            raise HTTPException(502, "failed to create schedule cron") from exc
        await _delete_cron(existing.get("cron_id"))
        updated["cron_id"] = new_cron_id
    elif updated.get("enabled") is False and existing.get("cron_id"):
        await _delete_cron(existing.get("cron_id"))
        updated["cron_id"] = None

    updated = await _put_value(updated)
    return _schedule_summary(updated)


async def delete_agent_schedule(schedule_id: str, login: str, *, email: str | None = None) -> None:
    existing = await get_agent_schedule(schedule_id)
    _assert_schedule_owner(existing, login, email)
    assert existing is not None
    await _delete_cron(existing.get("cron_id"))
    await _client().store.delete_item(SCHEDULES_NAMESPACE, schedule_id)


def _agent_run_metadata(record: dict[str, Any], thread_id: str) -> dict[str, Any]:
    repo = record.get("repo") if isinstance(record.get("repo"), dict) else None
    now_ms = _now_ms()
    metadata: dict[str, Any] = {
        "source": "schedule",
        "schedule_id": record["id"],
        "schedule_name": record.get("name"),
        "github_login": record.get("created_by"),
        "triggering_user_email": record.get("user_email"),
        "title": f"Scheduled: {record.get('name') or 'Agent'}",
        "base_branch": record.get("base_branch") or "main",
        "branch_prefix": record.get("branch_prefix"),
        "model": record.get("model") or "Default",
        "effort": record.get("effort"),
        "created_at_ms": now_ms,
        "updated_at_ms": now_ms,
    }
    if repo and repo.get("owner") and repo.get("name"):
        metadata["repo_owner"] = repo["owner"]
        metadata["repo_name"] = repo["name"]
    return metadata


def _agent_run_config(record: dict[str, Any], thread_id: str) -> dict[str, Any]:
    configurable: dict[str, Any] = {
        "thread_id": thread_id,
        "source": "schedule",
        "github_login": record.get("created_by"),
        "user_email": record.get("user_email"),
        "schedule_id": record["id"],
        "prepare_run_id": str(uuid.uuid4()),
    }
    repo = record.get("repo") if isinstance(record.get("repo"), dict) else None
    if repo and repo.get("owner") and repo.get("name"):
        configurable["repo"] = repo
    model, effort = _normalize_model_choice(record.get("model"), record.get("effort"))
    if model and effort:
        configurable["agent_model_id"] = model
        configurable["agent_effort"] = effort
    return {"configurable": configurable, "metadata": _agent_version_metadata()}


async def launch_scheduled_agent_run(schedule_id: str) -> dict[str, Any]:
    record = await get_agent_schedule(schedule_id)
    if not record:
        return {"status": "missing", "schedule_id": schedule_id}
    if not record.get("enabled"):
        return {"status": "disabled", "schedule_id": schedule_id}

    repo = record.get("repo") if isinstance(record.get("repo"), dict) else None
    full_name = _repo_full_name(repo)
    login = record.get("created_by")
    if full_name:
        if not (isinstance(login, str) and login):
            await _put_value(
                {
                    **record,
                    "last_error": "schedule owner unavailable",
                    "last_error_at": _now_iso(),
                }
            )
            return {
                "status": "unauthorized",
                "schedule_id": schedule_id,
                "error": "schedule owner unavailable",
            }
        try:
            await require_repo_access_for_user(login, full_name)
        except HTTPException as exc:
            await _put_value(
                {
                    **record,
                    "last_error": str(exc.detail),
                    "last_error_at": _now_iso(),
                }
            )
            return {"status": "unauthorized", "schedule_id": schedule_id, "error": exc.detail}

    thread_id = str(uuid.uuid4())
    metadata = _agent_run_metadata(record, thread_id)
    client = _client()
    await client.threads.create(thread_id=thread_id, metadata=metadata, if_exists="do_nothing")
    await client.threads.update(thread_id=thread_id, metadata=metadata)
    run = await client.runs.create(
        thread_id,
        _AGENT_ASSISTANT_ID,
        input={"messages": [{"role": "user", "content": record["prompt"]}]},
        config=_agent_run_config(record, thread_id),
        if_not_exists="create",
        stream_mode=["values", "updates", "messages-tuple"],
        stream_resumable=True,
    )
    run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
    now_ms = _now_ms()
    await client.threads.update(
        thread_id=thread_id,
        metadata={"latest_run_id": run_id, "latest_run_status": "pending", "updated_at_ms": now_ms},
    )
    await _put_value(
        {
            **record,
            "last_thread_id": thread_id,
            "last_run_id": run_id,
            "last_triggered_at": _now_iso(),
            "last_error": None,
            "last_error_at": None,
        }
    )
    return {
        "status": "started",
        "schedule_id": schedule_id,
        "thread_id": thread_id,
        "run_id": run_id,
    }
