"""Project management routes."""
import logging

from quart import Blueprint, g, jsonify, request

from scribe.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_pagination
from scribe.services import inception as inception_svc
from scribe.services.milestones import list_milestones
from scribe.services.notes import list_notes
from scribe.services.projects import (
    create_project,
    delete_project,
    get_project,
    get_project_for_user,
    get_project_summaries,
    get_project_summary,
    list_projects_for_user,
    update_project,
)

logger = logging.getLogger(__name__)

projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects")


@projects_bp.route("", methods=["GET"])
@login_required
async def list_projects_route():
    uid = get_current_user_id()
    status = request.args.get("status")
    include_summary = request.args.get("include_summary", "").lower() in ("1", "true")
    projects = await list_projects_for_user(uid, status=status)
    if include_summary:
        # Batched: four queries plus two, in two sessions, for ALL projects.
        # This replaced an asyncio.gather over a per-project summary that opened
        # its own session and then one more per milestone — ~250 concurrent
        # checkouts against a pool of 15, all waiting out the 30s timeout and
        # starving every other route on the instance (#2384).
        #
        # Grouped by OWNER because a shared project's counts belong to its
        # owner's records, matching what the per-project path passed.
        by_owner: dict[int, list[dict]] = {}
        for p in projects:
            by_owner.setdefault(p.get("user_id") or uid, []).append(p)
        for owner_uid, owned in by_owner.items():
            try:
                summaries = await get_project_summaries(
                    owner_uid, [p["id"] for p in owned]
                )
            except Exception:
                logger.warning("Project summaries failed", exc_info=True)
                continue
            for p in owned:
                if p["id"] in summaries:
                    p["summary"] = summaries[p["id"]]
    return jsonify({"projects": projects})


@projects_bp.route("", methods=["POST"])
@login_required
async def create_project_route():
    uid = get_current_user_id()
    data = await request.get_json()
    if not data.get("title"):
        return jsonify({"error": "title is required"}), 400
    status = data.get("status", "active")
    if status not in ("active", "paused", "completed", "archived"):
        return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
    # The inception decision rides the create (milestone 297): the UI's
    # second step sends `inception: {choices}`; absent = undecided, and the
    # project page shows the card until it is. Validated before the create
    # so a bad decision never leaves a half-made project behind.
    inception = data.get("inception")
    if inception is not None:
        error = inception_svc.validate_inception(inception)
        if error:
            return jsonify({"error": error}), 400
    project = await create_project(
        uid,
        title=data["title"],
        description=data.get("description", ""),
        goal=data.get("goal", ""),
        color=data.get("color"),
        status=status,
    )
    out = project.to_dict()
    if inception is not None:
        try:
            decided = await inception_svc.decide(uid, project.id, choices=inception, via="ui")
        except ValueError as exc:
            return jsonify({"error": str(exc), "project": out}), 400
        out["inception"] = decided["inception"]
        out["inception_effects"] = decided["effects"]
    return jsonify(out), 201


@projects_bp.route("/<int:project_id>/inception", methods=["POST"])
@login_required
async def decide_inception_route(project_id: int):
    """Record (or re-record) what a project inherits — milestone 297.
    Body: the choices object {exclude_always_on_rulebooks, subscribe_rulebooks,
    design_system_id, seed_systems}; owner-only."""
    uid = get_current_user_id()
    data = await request.get_json() or {}
    choices = data.get("choices", data)
    try:
        decided = await inception_svc.decide(uid, project_id, choices=choices, via="ui")
    except ValueError as exc:
        msg = str(exc)
        status = 404 if "not found" in msg else 400
        return jsonify({"error": msg}), status
    return jsonify({"project_id": project_id, **decided})


@projects_bp.route("/<int:project_id>/inception/defaults", methods=["GET"])
@login_required
async def inception_defaults_route(project_id: int):
    """What the project inherits if nobody decides — the card's payload."""
    uid = get_current_user_id()
    try:
        return jsonify(await inception_svc.current_defaults(uid, project_id))
    except ValueError:
        return not_found("Project")


@projects_bp.route("/<int:project_id>", methods=["GET"])
@login_required
async def get_project_route(project_id: int):
    uid = get_current_user_id()
    result = await get_project_for_user(uid, project_id)
    if result is None:
        return not_found("Project")
    project, permission = result
    # Summary uses the project owner's uid for stats when viewer is not the owner
    owner_uid = project.user_id or uid
    summary = await get_project_summary(owner_uid, project_id)
    data = project.to_dict()
    data["summary"] = summary
    data["permission"] = permission
    return jsonify(data)


@projects_bp.route("/<int:project_id>", methods=["PATCH"])
@login_required
async def update_project_route(project_id: int):
    uid = get_current_user_id()
    data = await request.get_json()
    allowed = {"title", "description", "goal", "status", "color"}
    fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
    if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
        return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
    project = await update_project(uid, project_id, **fields)
    if project is None:
        return not_found("Project")
    return jsonify(project.to_dict())


@projects_bp.route("/<int:project_id>", methods=["DELETE"])
@login_required
async def delete_project_route(project_id: int):
    uid = get_current_user_id()
    from scribe.services.trash import delete as trash_delete
    batch = await trash_delete(uid, "project", project_id)
    if batch is None:
        return not_found("Project")
    return "", 204


@projects_bp.route("/<int:project_id>/coverage", methods=["GET"])
@login_required
async def get_coverage_route(project_id: int):
    """The cached shape-accounting summary — never computes.

    `configured` tells the card whether offering a Refresh button makes
    sense; `coverage` is null until something has computed it (a webhook
    push or an explicit refresh).
    """
    from scribe.services.coverage import cached_coverage
    from scribe.services.forge import get_forges

    uid = get_current_user_id()
    result = await get_project_for_user(uid, project_id)
    if result is None:
        return not_found("Project")
    project, _ = result
    owner_uid = project.user_id or uid
    return jsonify({
        # The OWNER's keyring (#2778) — whether refresh could do anything,
        # regardless of who is looking.
        "configured": (await get_forges(owner_uid, project_id)).configured,
        "coverage": await cached_coverage(owner_uid, project_id),
    })


@projects_bp.route("/<int:project_id>/coverage/refresh", methods=["POST"])
@login_required
async def refresh_coverage_route(project_id: int):
    """Recompute coverage now (archive fetch — seconds, not milliseconds).

    Synchronous on purpose: the caller is a person who just clicked Refresh
    and wants the new number, and the forge timeout bounds the wait.
    """
    from scribe.services.coverage import refresh_coverage
    from scribe.services.forge import ForgeError, get_forges

    uid = get_current_user_id()
    result = await get_project_for_user(uid, project_id)
    if result is None:
        return not_found("Project")
    project, _ = result
    owner_uid = project.user_id or uid
    selector = await get_forges(owner_uid, project_id)
    if not selector.configured:
        return jsonify({
            "error": "The project owner has no forge connection "
            "(Settings → Git forges)"
        }), 400
    try:
        coverage = await refresh_coverage(owner_uid, project_id, selector=selector)
    except ForgeError as exc:
        return jsonify({"error": str(exc)}), 502
    if coverage is None:
        return jsonify({
            "error": "No bound repo is served by the owner's forge connections — "
            "bind the project's repo (bind_repo) on a remote a connection hosts"
        }), 400
    return jsonify({"coverage": coverage})


@projects_bp.route("/<int:project_id>/forge", methods=["PUT"])
@login_required
async def set_project_forge_route(project_id: int):
    """Pin the project to one forge connection, or clear the pin (#2778).

    Body: {"connection_id": <id> | null}. Owner-or-admin may ask; either way
    the pin can only reference a connection the project OWNER holds — the
    service enforces that, so a collaborator's token can never end up serving
    someone else's project.
    """
    from scribe.services.forge_connections import set_project_pin

    uid = get_current_user_id()
    result = await get_project_for_user(uid, project_id)
    if result is None:
        return not_found("Project")
    project, permission = result
    if permission != "owner" and g.user.role != "admin":
        return jsonify({"error": "Only the project owner can change its forge"}), 403

    data = await request.get_json() or {}
    raw = data.get("connection_id")
    if raw is not None and not isinstance(raw, int):
        return jsonify({"error": "connection_id must be an integer or null"}), 400
    owner_uid = project.user_id or uid
    if not await set_project_pin(owner_uid, project_id, raw):
        return jsonify({
            "error": "That connection does not belong to the project owner"
        }), 400
    return jsonify({"forge_connection_id": raw})


@projects_bp.route("/<int:project_id>/notes", methods=["GET"])
@login_required
async def get_project_notes_route(project_id: int):
    uid = get_current_user_id()
    result = await get_project_for_user(uid, project_id)
    if result is None:
        return not_found("Project")
    project, _ = result
    # Use the project owner's uid so the ownership filter on notes/milestones
    # matches for shared collaborators (who'd otherwise see an empty panel).
    owner_uid = project.user_id or uid

    # type filter: "note", "task", or None (both)
    type_filter = request.args.get("type")
    status_filter = request.args.get("status")
    limit, offset = parse_pagination(default_limit=100)

    is_task: bool | None = None
    if type_filter == "task":
        is_task = True
    elif type_filter == "note":
        is_task = False

    ms_list = await list_milestones(owner_uid, project_id)
    milestone_ids = [m.id for m in ms_list]

    notes, total = await list_notes(
        owner_uid,
        is_task=is_task,
        status=status_filter,
        project_id=project_id,
        milestone_ids=milestone_ids,
        limit=limit,
        offset=offset,
        sort="updated_at",
        order="desc",
    )
    return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
