"""Projects and stages — the spine.

Two decisions are load-bearing here and are enforced by this module's shape:

  * **Project create is ONE path that makes the kit instance AND the project
    row together.** Split them and the kit's invite/grant screens have nothing
    to grant on, which is exactly how the M1 gate fails (§5.1).
  * **Stages are concurrent workstreams, not a state machine.** Any number may
    be active at once, each closes independently, and `position` is the
    planned display order and nothing more. There is no cursor, no
    auto-advance, and no derived ball-in-court: caddie renders where the
    project is; the TOOLS own who has the ball (D13, D14).
"""

import re

from sqlalchemy import select

from app import bw_accounts as bwa
from app.models import (
    Approval, Project, ProjectField, ProjectSeen, ProjectTemplate,
    ProjectTemplateStage, ReferenceSection, Stage, StageTemplate, ToolAssignment,
    utcnow,
)
from app.services import activity

STAGE_STATUSES = {"planned", "active", "done", "skipped"}
PROJECT_STATES = {"active", "paused", "complete", "archived"}


class ProjectError(Exception):
    def __init__(self, message, code="BAD_INPUT"):
        self.code = code
        super().__init__(message)


def slugify(name: str) -> str:
    s = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
    return s[:80]


# ─────────────────────────────────────────────────────────────── create

def create(session, actor: str, *, display_name: str, template_key: str = "",
           client_name_hint: str = "", slug: str = "") -> Project:
    """Create the kit instance and the project row together, then deep-copy the
    template's stages if one was named."""
    display_name = (display_name or "").strip()
    if not display_name:
        raise ProjectError("A project needs a name.")
    pid = slug or slugify(display_name)
    if not pid:
        raise ProjectError("That name doesn't produce a usable id.")
    if session.get(Project, pid):
        raise ProjectError(f"A project '{pid}' already exists.", "EXISTS")

    # the kit instance first — if this fails we have written nothing
    bwa.create_instance(actor, pid, display_name)

    row = Project(id=pid, display_name=display_name, state="active",
                  client_name_hint=client_name_hint or None, created_by=actor)
    session.add(row)
    session.flush()
    activity.record(session, pid, "project.created", actor=actor,
                    payload={"display_name": display_name})

    if template_key:
        instantiate_template(session, actor, pid, template_key)
    return row


def instantiate_template(session, actor: str, project_id: str, template_key: str) -> int:
    """Deep-copy a project template's stage slots into brand-new stage rows.

    No FK back to the template: editing or deleting a template can never reach
    into a live project (§5.5). Tool slots copy a SUGGESTION only — the
    assignment happens when the agency explicitly attaches.
    """
    tpl = session.get(ProjectTemplate, template_key)
    if tpl is None:
        raise ProjectError(f"No template '{template_key}'.", "NOT_FOUND")
    project = session.get(Project, project_id)
    slots = list(session.execute(
        select(ProjectTemplateStage)
        .where(ProjectTemplateStage.template_key == template_key)
        .order_by(ProjectTemplateStage.position)).scalars())

    made = 0
    for slot in slots:
        st = session.get(StageTemplate, slot.stage_template_key)
        if st is None:
            continue
        over = slot.overrides or {}
        session.add(Stage(
            project_id=project_id, position=slot.position,
            title=_subst(over.get("title", st.title), project),
            body_md=_subst(over.get("body_md", st.body_md), project),
            links=over.get("links", st.links),
            requires_approval=bool(over.get("requires_approval", st.requires_approval)),
            client_visible=bool(over.get("client_visible", st.client_visible)),
            source_template_key=st.key, status="planned"))
        made += 1
    session.flush()
    activity.record(session, project_id, "project.template_applied", actor=actor,
                    payload={"template": template_key, "stages": made})
    return made


def _subst(text: str, project: Project | None) -> str:
    """`{{client_name}}` / `{{project_name}}` at copy time. Unknown tokens pass
    through literally — a template is prose, not a program."""
    if not text or not project:
        return text or ""
    return (text
            .replace("{{project_name}}", project.display_name)
            .replace("{{client_name}}", project.client_name_hint or project.display_name))


# ──────────────────────────────────────────────────────────────── stages

def add_stage(session, actor: str, project_id: str, *, title: str, **kw) -> Stage:
    if not (title or "").strip():
        raise ProjectError("A stage needs a title.")
    existing = session.execute(
        select(Stage.position).where(Stage.project_id == project_id)).scalars().all()
    st = Stage(project_id=project_id, title=title.strip(),
               position=kw.get("position", (max(existing) + 1) if existing else 0),
               body_md=kw.get("body_md", ""), links=kw.get("links"),
               requires_approval=bool(kw.get("requires_approval", False)),
               client_visible=bool(kw.get("client_visible", True)),
               target_date=kw.get("target_date"))
    session.add(st)
    session.flush()
    activity.record(session, project_id, "stage.added", actor=actor, stage_id=st.id,
                    payload={"title": st.title}, client_visible=st.client_visible)
    return st


def set_stage_status(session, actor: str, stage: Stage, status: str) -> Stage:
    """Start / close / skip / reopen ONE stage. Never a cursor: the caller
    names the stage, and every other stage is untouched."""
    if status not in STAGE_STATUSES:
        raise ProjectError(f"Unknown stage status '{status}'.")
    if status == stage.status:
        return stage
    stage.status = status
    if status == "active":
        stage.started_at = stage.started_at or utcnow()
        stage.closed_at = None
    elif status in ("done", "skipped"):
        stage.closed_at = utcnow()
    else:                                    # reopened to planned
        stage.closed_at = None
    session.flush()
    activity.record(session, stage.project_id, f"stage.{status}", actor=actor,
                    stage_id=stage.id, payload={"title": stage.title},
                    client_visible=stage.client_visible)
    return stage


def approve(session, actor: str, actor_display: str, stage: Stage, note: str = "") -> Approval:
    """Append-only, with the display name snapshot so the record still reads
    correctly after account churn."""
    if not stage.requires_approval:
        raise ProjectError("This stage doesn't ask for an approval.", "NO_APPROVAL")
    if stage.status not in ("active", "done"):
        raise ProjectError("This stage isn't open for approval yet.", "NOT_ACTIVE")
    if approval_for(session, stage.id):
        raise ProjectError("This stage has already been approved.", "EXISTS")
    row = Approval(stage_id=stage.id, project_id=stage.project_id,
                   label=stage.title, approved_by=actor,
                   approved_by_display=actor_display or actor, note=note or "")
    session.add(row)
    session.flush()
    activity.record(session, stage.project_id, "approval.given", actor=actor,
                    actor_type="client", stage_id=stage.id,
                    payload={"title": stage.title})
    return row


def approval_for(session, stage_id: str) -> Approval | None:
    return session.execute(
        select(Approval).where(Approval.stage_id == stage_id)).scalars().first()


def stages_of(session, project_id: str) -> list[Stage]:
    return list(session.execute(
        select(Stage).where(Stage.project_id == project_id)
        .order_by(Stage.position, Stage.title)).scalars())


def mark_seen(session, project_id: str, username: str) -> None:
    row = session.get(ProjectSeen, (project_id, username))
    if row is None:
        session.add(ProjectSeen(project_id=project_id, username=username))
    else:
        row.last_seen_at = utcnow()
    session.flush()


def last_seen(session, project_id: str, username: str):
    row = session.get(ProjectSeen, (project_id, username))
    return row.last_seen_at if row else None


# ───────────────────────────────────────────────────────────── rendering

def render_project(session, project: Project, *, for_client: bool,
                   username: str = "") -> dict:
    """One shape for both audiences; `for_client` decides what is withheld.

    "Needed from you" is a CONCATENATION, never a computation: open approvals
    on active stages, plus whatever attached tools reported as waiting on this
    person. Caddie does not decide who has the ball (D14).
    """
    stages = stages_of(session, project.id)
    if for_client:
        stages = [s for s in stages if s.client_visible and s.status != "skipped"]

    assignments = {a.stage_id: a for a in session.execute(
        select(ToolAssignment).where(ToolAssignment.project_id == project.id)).scalars()}
    approvals = {a.stage_id: a for a in session.execute(
        select(Approval).where(Approval.project_id == project.id)).scalars()}

    out_stages, needed = [], []
    for s in stages:
        appr = approvals.get(s.id)
        asg = assignments.get(s.id)
        tool = None
        if asg:
            tool = {"slug": asg.tool_slug, "assignment_id": asg.id,
                    "status": asg.reported_status, "summary": asg.reported_summary,
                    "client_open": asg.client_open_count,
                    "agency_open": asg.agency_open_count,
                    "last_report_at": asg.last_report_at.isoformat()
                    if asg.last_report_at else None}
        out_stages.append({
            "id": s.id, "title": s.title, "body_md": s.body_md, "links": s.links or [],
            "status": s.status, "position": s.position,
            "target_date": s.target_date.isoformat() if s.target_date else None,
            "requires_approval": s.requires_approval,
            "client_visible": s.client_visible,
            "started_at": s.started_at.isoformat() if s.started_at else None,
            "closed_at": s.closed_at.isoformat() if s.closed_at else None,
            "approval": ({"by": appr.approved_by_display, "at": appr.approved_at.isoformat(),
                          "note": appr.note} if appr else None),
            "tool": tool,
        })
        if s.status != "active":
            continue
        if s.requires_approval and not appr:
            needed.append({"kind": "approval", "stage_id": s.id, "title": s.title})
        if asg and asg.reported_status == "waiting_client" and asg.client_open_count > 0:
            needed.append({"kind": "tool", "stage_id": s.id, "title": s.title,
                           "tool": asg.tool_slug, "count": asg.client_open_count,
                           "summary": asg.reported_summary})

    fields = _render_fields(session, project.id, for_client=for_client)
    refs = list(session.execute(
        select(ReferenceSection).where(ReferenceSection.project_id == project.id)
        .order_by(ReferenceSection.position)).scalars())
    if for_client:
        refs = [r for r in refs if r.client_visible]

    return {
        "id": project.id, "display_name": project.display_name, "state": project.state,
        "client_name_hint": project.client_name_hint,
        "active_stages": [s["title"] for s in out_stages if s["status"] == "active"],
        "stages": out_stages,
        "needed_from_you": needed if for_client else [],
        "fields": fields,
        "reference": [{"id": r.id, "title": r.title, "body_md": r.body_md,
                       "links": r.links or [], "client_visible": r.client_visible}
                      for r in refs],
        "last_seen_at": (last_seen(session, project.id, username).isoformat()
                         if username and last_seen(session, project.id, username) else None),
    }


def _render_fields(session, project_id: str, *, for_client: bool) -> list[dict]:
    from app.models import FieldDefinition
    rows = list(session.execute(
        select(ProjectField, FieldDefinition)
        .join(FieldDefinition, FieldDefinition.key == ProjectField.definition_key)
        .where(ProjectField.project_id == project_id)
        .order_by(ProjectField.position)).all())
    out = []
    for val, defn in rows:
        if for_client and not val.client_visible:
            continue
        # `options` rides along with the value: the dictionary's promise is
        # that the label AND the shape are the same on every project, so the
        # editor must not have to fetch the definition to know what to draw.
        out.append({"key": defn.key, "label": defn.label, "type": defn.type,
                    "value": val.value, "client_visible": val.client_visible,
                    "help": defn.help, "options": defn.options or None})
    return out
