"""Task, project, result, and batch endpoints plus shared helpers."""

from __future__ import annotations

import os
from typing import Any

from aiohttp import web

from dobby.core.introspect import parse_agent_log
from dobby.models import (
    Artifact,
    InfraConfig,
    InvalidTransitionError,
    Priority,
    Project,
    ProjectPolicies,
    Schedule,
    ScheduleType,
    Task,
    TaskMode,
    TaskStatus,
    generate_batch_id,
)

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

def _task_from_body(body: dict[str, Any]) -> Task:
    """Build a Task from a JSON body dict."""
    kwargs: dict[str, Any] = {
        "project": body["project"],
        "description": body["description"],
    }
    if "title" in body:
        kwargs["title"] = body["title"]
    if "exit_criteria" in body:
        kwargs["exit_criteria"] = body["exit_criteria"]
    if "priority" in body:
        kwargs["priority"] = Priority(body["priority"])
    if "mode" in body:
        kwargs["mode"] = TaskMode(body["mode"])
    if "checkpoint" in body:
        kwargs["checkpoint"] = bool(body["checkpoint"])
    sched_dict = body.get("schedule")
    if sched_dict is None:
        # Backwards compatibility for older CLI clients.
        if body.get("cron"):
            sched_dict = {"type": "cron", "expr": body["cron"]}
        elif body.get("every"):
            sched_dict = {"type": "interval", "every": body["every"]}
        elif body.get("after"):
            sched_dict = {"type": "after", "depends_on": body["after"]}
    if sched_dict is not None:
        kwargs["schedule"] = Schedule(
            type=ScheduleType(sched_dict.get("type", "once")),
            expr=sched_dict.get("expr"),
            every=sched_dict.get("every"),
            until=sched_dict.get("until"),
            depends_on=sched_dict.get("depends_on"),
        )
    if "infra" in body:
        kwargs["infra"] = body["infra"]
    if "timeout" in body:
        kwargs["timeout"] = body["timeout"]
    if "session_id" in body:
        kwargs["session_id"] = body["session_id"]
    if "backend" in body:
        kwargs["backend"] = body["backend"]
    if "target" in body:
        kwargs["target"] = body["target"]
    if "batch_id" in body:
        kwargs["batch_id"] = body["batch_id"]
    if "trace_id" in body:
        kwargs["trace_id"] = body["trace_id"]
    return Task(**kwargs)


def _project_from_body(body: dict[str, Any]) -> Project:
    """Build a Project from a JSON body dict."""
    infra = [InfraConfig.from_dict(i) for i in body.get("infra", [])]
    policies_raw = body.get("policies")
    policies = ProjectPolicies.from_dict(policies_raw) if policies_raw else ProjectPolicies()
    return Project(
        name=body["name"],
        repo=body["repo"],
        context=body.get("context"),
        git_remote=body.get("git_remote"),
        claude_md=body.get("claude_md", True),
        infra=infra,
        policies=policies,
    )


# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------

async def health(request: web.Request) -> web.Response:
    return web.json_response({"status": "ok"})


async def remote_status(request: web.Request) -> web.Response:
    """Check Claude Code remote control session status."""
    session = request.app.get("remote_session")
    if session is None:
        return web.json_response({"enabled": False, "running": False})
    return web.json_response({"enabled": True, **session.status()})


async def create_task(request: web.Request) -> web.Response:
    store = request.app["store"]
    body = await request.json()

    # Extract and remove trace context (not a task field)
    trace_ctx = body.pop("_trace_context", None)

    # Merge project policies into the request body before building the task
    project = await store.get_project(body["project"])
    if project is not None:
        policies = project.policies
        if policies.default_backend and "backend" not in body:
            body["backend"] = policies.default_backend
        if policies.default_timeout is not None and "timeout" not in body:
            body["timeout"] = policies.default_timeout
        if policies.default_priority and "priority" not in body:
            body["priority"] = policies.default_priority
        # Auto-chaining: if enabled and no explicit schedule, depend on latest pending/running task
        if policies.auto_chain and body.get("schedule") is None and not body.get("after"):
            latest = await _find_latest_chainable_task(store, body["project"])
            if latest is not None:
                body["schedule"] = {"type": "after", "depends_on": latest.id}

    # Create trajectory trace if context was provided
    trace_id = None
    if trace_ctx:
        try:
            from dobby.core.trajectory import trajectory_parser
            trace_id = await trajectory_parser.create_trace(
                store,
                source=trace_ctx.get("source", "api"),
                source_id=trace_ctx.get("source_id"),
                user_id=trace_ctx.get("user_id"),
                project=body.get("project"),
                input_text=trace_ctx.get("input_text"),
            )
            # Input span
            input_meta = {
                k: v for k, v in trace_ctx.items()
                if k not in ("source", "source_id", "user_id", "input_text")
            }
            await trajectory_parser.add_span(
                store, trace_id, kind="input", metadata=input_meta or None,
            )
        except Exception:
            import logging
            logging.getLogger(__name__).debug("Trace creation failed", exc_info=True)

    # Attach trace_id to the task body so Task model stores it
    if trace_id:
        body["trace_id"] = trace_id

    task = _task_from_body(body)
    await store.create_task(task)

    # Decision span
    if trace_id:
        try:
            from dobby.core.trajectory import trajectory_parser
            await trajectory_parser.add_span(
                store, trace_id, kind="decision", task_id=task.id,
                metadata={
                    "project": task.project,
                    "target": task.target,
                    "priority": task.priority.value,
                    "mode": task.mode.value,
                },
            )
        except Exception:
            pass

    return web.json_response(task.to_dict(), status=201)


async def _find_latest_chainable_task(store, project_name: str):
    """Find the most recent pending/running/active task for a project (for auto-chaining)."""
    all_tasks = await store.list_tasks(project=project_name)
    chainable = [
        t for t in all_tasks
        if t.status in (TaskStatus.PENDING, TaskStatus.RUNNING, TaskStatus.ACTIVE)
    ]
    if not chainable:
        return None
    # Most recently created
    chainable.sort(key=lambda t: t.created_at, reverse=True)
    return chainable[0]


async def list_tasks(request: web.Request) -> web.Response:
    store = request.app["store"]
    project = request.rel_url.query.get("project")
    status = request.rel_url.query.get("status")
    search = request.rel_url.query.get("search")
    sort = request.rel_url.query.get("sort", "created_at")
    limit_raw = request.rel_url.query.get("limit")
    offset_raw = request.rel_url.query.get("offset")
    limit = int(limit_raw) if limit_raw is not None else None
    offset = int(offset_raw) if offset_raw is not None else 0

    tasks = await store.list_tasks(
        project=project, status=status, search=search, sort=sort,
    )
    total = len(tasks)
    tasks = tasks[offset:]
    if limit is not None:
        tasks = tasks[:limit]
    body: dict[str, Any] = {
        "items": [t.to_dict() for t in tasks],
        "total": total,
        "offset": offset,
        "limit": limit,
    }
    return web.json_response(body)


async def get_task(request: web.Request) -> web.Response:
    store = request.app["store"]
    task_id = request.match_info["id"]
    try:
        task = await store.get_task(task_id)
    except KeyError as exc:
        raise web.HTTPNotFound(reason=f"Task not found: {task_id}") from exc
    return web.json_response(task.to_dict())


async def patch_task(request: web.Request) -> web.Response:
    store = request.app["store"]
    dispatcher = request.app.get("dispatcher")
    task_id = request.match_info["id"]
    body = await request.json()
    try:
        task = await store.get_task(task_id)
    except KeyError as exc:
        raise web.HTTPNotFound(reason=f"Task not found: {task_id}") from exc
    try:
        await store.update_task(task_id, **body)
    except InvalidTransitionError as exc:
        raise web.HTTPConflict(reason=str(exc)) from exc
    if (
        body.get("status") == TaskStatus.CANCELLED.value
        and dispatcher is not None
        and task.status == TaskStatus.RUNNING
    ):
        await dispatcher.cancel(task_id)
    task = await store.get_task(task_id)
    return web.json_response(task.to_dict())


async def retry_task(request: web.Request) -> web.Response:
    store = request.app["store"]
    task_id = request.match_info["id"]
    cascade = request.rel_url.query.get("cascade", "").lower() in ("true", "1", "yes")
    try:
        task = await store.get_task(task_id)
    except KeyError as exc:
        raise web.HTTPNotFound(reason=f"Task not found: {task_id}") from exc

    if task.status == TaskStatus.RUNNING:
        raise web.HTTPConflict(reason=f"Task is still running: {task_id}")

    reset_status = (
        TaskStatus.ACTIVE.value
        if task.schedule.type in (ScheduleType.CRON, ScheduleType.INTERVAL)
        else TaskStatus.PENDING.value
    )
    await store.update_task(
        task_id,
        status=reset_status,
        started_at=None,
        consecutive_failures=0,
    )
    updated = await store.get_task(task_id)

    retried_tasks = [updated.to_dict()]
    if cascade:
        dependents = await _collect_cascade_dependents(store, task_id)
        for dep in dependents:
            if dep.status in (TaskStatus.RUNNING,):
                continue  # skip still-running tasks
            dep_reset = (
                TaskStatus.ACTIVE.value
                if dep.schedule.type in (ScheduleType.CRON, ScheduleType.INTERVAL)
                else TaskStatus.ACTIVE.value  # AFTER tasks reset to ACTIVE (waiting)
            )
            await store.update_task(
                dep.id,
                status=dep_reset,
                started_at=None,
                consecutive_failures=0,
            )
            retried_dep = await store.get_task(dep.id)
            retried_tasks.append(retried_dep.to_dict())

    if cascade:
        return web.json_response({"retried": retried_tasks}, status=201)
    return web.json_response(updated.to_dict(), status=201)


async def _collect_cascade_dependents(store, task_id: str) -> list:
    """Recursively collect all downstream dependent tasks."""
    result = []
    direct = await store.get_dependent_tasks(task_id)
    for dep in direct:
        result.append(dep)
        # Recurse to find transitive dependents
        result.extend(await _collect_cascade_dependents(store, dep.id))
    return result


async def get_task_events(request: web.Request) -> web.Response:
    store = request.app["store"]
    task_id = request.match_info["id"]
    try:
        task = await store.get_task(task_id)
    except KeyError as exc:
        raise web.HTTPNotFound(reason=f"Task not found: {task_id}") from exc
    return web.json_response([e.to_dict() for e in task.events])


async def get_task_artifacts(request: web.Request) -> web.Response:
    store = request.app["store"]
    task_id = request.match_info["id"]
    try:
        await store.get_task(task_id)
    except KeyError as exc:
        raise web.HTTPNotFound(reason=f"Task not found: {task_id}") from exc
    results = await store.list_results(task_id=task_id)
    artifacts = []
    for result in results:
        for a in result.artifacts:
            artifacts.append(a.to_dict() if isinstance(a, Artifact) else a)
    return web.json_response(artifacts)


async def get_task_detail_status(request: web.Request) -> web.Response:
    """Return structured introspection of a running (or completed) task's agent log."""
    store = request.app["store"]
    task_id = request.match_info["id"]
    try:
        task = await store.get_task(task_id)
    except KeyError as exc:
        raise web.HTTPNotFound(reason=f"Task not found: {task_id}") from exc

    results_dir = request.app.get("results_dir", "results")
    log_path = os.path.join(results_dir, task_id, "agent.log")

    log_content = ""
    if os.path.exists(log_path):
        with open(log_path) as f:
            log_content = f.read()

    ds = parse_agent_log(log_content, task_id, task.status.value)
    return web.json_response(ds.to_dict())


async def get_task_logs(request: web.Request) -> web.Response:
    task_id = request.match_info["id"]
    tail = request.rel_url.query.get("tail")

    results_dir = request.app.get("results_dir", "results")
    log_path = os.path.join(results_dir, task_id, "agent.log")

    if not os.path.exists(log_path):
        return web.json_response({"logs": "", "lines": 0})

    with open(log_path) as f:
        content = f.read()

    lines = content.splitlines()
    total_lines = len(lines)
    if tail:
        lines = lines[-int(tail):]

    return web.json_response({
        "logs": "\n".join(lines),
        "lines": len(lines),
        "total_lines": total_lines,
    })


async def get_task_session(request: web.Request) -> web.Response:
    """Return the session_id from a task's result, for conversation resume."""
    store = request.app["store"]
    task_id = request.match_info["id"]
    result = await store.get_result(task_id)
    if result is None or not result.session_id:
        raise web.HTTPNotFound(reason=f"No session_id found for task: {task_id}")
    return web.json_response({"session_id": result.session_id})


async def register_project(request: web.Request) -> web.Response:
    store = request.app["store"]
    body = await request.json()
    project = _project_from_body(body)
    await store.register_project(project)
    return web.json_response(project.to_dict(), status=201)


async def list_projects(request: web.Request) -> web.Response:
    store = request.app["store"]
    projects = await store.list_projects()
    return web.json_response([p.to_dict() for p in projects])


async def get_project(request: web.Request) -> web.Response:
    store = request.app["store"]
    name = request.match_info["name"]
    project = await store.get_project(name)
    if project is None:
        raise web.HTTPNotFound(reason=f"Project not found: {name}")
    return web.json_response(project.to_dict())


async def list_results(request: web.Request) -> web.Response:
    store = request.app["store"]
    project = request.rel_url.query.get("project")
    last_n_raw = request.rel_url.query.get("last_n")
    last_n = int(last_n_raw) if last_n_raw is not None else None
    search = request.rel_url.query.get("search")
    sort = request.rel_url.query.get("sort", "completed_at")
    limit_raw = request.rel_url.query.get("limit")
    offset_raw = request.rel_url.query.get("offset")
    limit = int(limit_raw) if limit_raw is not None else None
    offset = int(offset_raw) if offset_raw is not None else 0

    results = await store.list_results(project=project, last_n=last_n)
    # Apply search filter on summary
    if search:
        search_lower = search.lower()
        results = [r for r in results if search_lower in r.summary.lower()]
    # Sort
    if sort == "completed_at":
        results.sort(key=lambda r: r.completed_at)
    elif sort == "-completed_at":
        results.sort(key=lambda r: r.completed_at, reverse=True)
    total = len(results)
    results = results[offset:]
    if limit is not None:
        results = results[:limit]
    body: dict[str, Any] = {
        "items": [r.to_dict() for r in results],
        "total": total,
        "offset": offset,
        "limit": limit,
    }
    return web.json_response(body)


async def get_result(request: web.Request) -> web.Response:
    store = request.app["store"]
    task_id = request.match_info["task_id"]
    result = await store.get_result(task_id)
    if result is None:
        raise web.HTTPNotFound(reason=f"Result not found for task: {task_id}")
    return web.json_response(result.to_dict())


# ---------------------------------------------------------------------------
# Batch endpoints
# ---------------------------------------------------------------------------

async def create_batch(request: web.Request) -> web.Response:
    """Create multiple tasks from a batch payload. Returns the batch_id and created tasks."""
    store = request.app["store"]
    body = await request.json()
    tasks_data = body.get("tasks", [])
    if not tasks_data:
        raise web.HTTPBadRequest(reason="No tasks provided in batch")

    batch_id = generate_batch_id()
    created = []
    for task_body in tasks_data:
        task_body["batch_id"] = batch_id
        task = _task_from_body(task_body)
        await store.create_task(task)
        created.append(task.to_dict())

    return web.json_response({"batch_id": batch_id, "tasks": created}, status=201)


async def list_batches(request: web.Request) -> web.Response:
    """List all batches with aggregate progress."""
    store = request.app["store"]
    batches = await store.list_batches()
    return web.json_response(batches)


async def get_batch(request: web.Request) -> web.Response:
    """Get details of a specific batch including all its tasks."""
    store = request.app["store"]
    batch_id = request.match_info["batch_id"]
    tasks = await store.list_tasks_by_batch(batch_id)
    if not tasks:
        raise web.HTTPNotFound(reason=f"Batch not found: {batch_id}")

    total = len(tasks)
    completed = sum(1 for t in tasks if t.status == TaskStatus.COMPLETED)
    failed = sum(1 for t in tasks if t.status == TaskStatus.FAILED)
    running = sum(1 for t in tasks if t.status == TaskStatus.RUNNING)
    done = sum(
        1 for t in tasks
        if t.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED)
    )

    return web.json_response({
        "batch_id": batch_id,
        "total": total,
        "completed": completed,
        "failed": failed,
        "running": running,
        "done": done,
        "all_done": done == total,
        "tasks": [t.to_dict() for t in tasks],
    })


# ---------------------------------------------------------------------------
# Export endpoint
# ---------------------------------------------------------------------------

async def export_results(request: web.Request) -> web.Response:
    """Export task + result data as JSON or CSV."""
    store = request.app["store"]
    project = request.rel_url.query.get("project")
    fmt = request.rel_url.query.get("format", "json")

    tasks = await store.list_tasks(project=project)
    results_map: dict[str, Any] = {}
    all_results = await store.list_results(project=project)
    for r in all_results:
        results_map[r.task_id] = r

    rows = []
    for task in tasks:
        result = results_map.get(task.id)
        row = {
            "task_id": task.id,
            "project": task.project,
            "description": task.description,
            "status": task.status.value,
            "priority": task.priority.value,
            "batch_id": task.batch_id,
            "created_at": task.created_at.isoformat(),
            "result_status": result.status if result else None,
            "result_summary": result.summary if result else None,
            "cost_usd": (result.cost or {}).get("cost_usd", 0.0) if result else 0.0,
            "total_tokens": (result.cost or {}).get("total_tokens", 0) if result else 0,
            "completed_at": result.completed_at.isoformat() if result else None,
        }
        rows.append(row)

    if fmt == "csv":
        import csv
        import io
        output = io.StringIO()
        if rows:
            writer = csv.DictWriter(output, fieldnames=rows[0].keys())
            writer.writeheader()
            writer.writerows(rows)
        return web.Response(
            text=output.getvalue(),
            content_type="text/csv",
            headers={"Content-Disposition": "attachment; filename=dobby-export.csv"},
        )
    else:
        return web.json_response(rows)
