"""Scheduled: the list, the setup form, and one task chat's controls.

A schedule's own chat is rendered by the ordinary chat page — same transcript,
same tail poller, same canvas — with the composer replaced by a strip of
controls. That is the whole reason `KIND_TASK` reuses `Chat` and `Message`
rather than growing tables of its own.

The rule form here is the **manual** one, and it is not a fallback in the
apologetic sense: it is what makes "an empty override means off" safe for the
compile step in Phase 3. Clearing `task.schedule_compile` must switch off the
*compiling*, not the feature.
"""

from __future__ import annotations

import logging
from datetime import UTC, datetime

from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response

from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.api.pages import sidebar_context
from lembas.db.models import TARGET_CHAT, TARGET_MESSAGES, TARGET_REPORT, Schedule
from lembas.services import chat as chat_service
from lembas.services import schedules as schedules_service
from lembas.services.schedule import clock, runner
from lembas.services.schedule import rule as rule_service
from lembas.web.templating import render

log = logging.getLogger(__name__)

router = APIRouter(
    dependencies=[Depends(require_permission("schedule.use"))], tags=["schedules"]
)

# What the setup form may ask for, in the order they are offered.
OFFERED_TARGETS = (
    (TARGET_CHAT, "Its own chat"),
    (TARGET_REPORT, "Reports"),
    (TARGET_MESSAGES, "Messages"),
)

REPEAT_ONCE = "once"
REPEAT_EVERY = "every"
REPEAT_CALENDAR = "calendar"


def _rule_from_form(form) -> dict:
    """Build a rule dict out of the setup form's fields.

    Deliberately builds the *raw* shape and hands it to `rule.validate` rather
    than validating here: there is one normaliser, it is total, and it is the
    same one a model's compiled output will go through in Phase 3. Two
    validators would be two ideas of what a legal schedule is.
    """
    repeat = str(form.get("repeat") or REPEAT_ONCE)
    raw: dict = {}

    when = str(form.get("start_date") or "").strip()
    at_time = str(form.get("start_time") or "").strip() or "09:00"
    if when:
        raw["start"] = f"{when}T{at_time}:00"

    if repeat == REPEAT_EVERY:
        unit = str(form.get("every_unit") or "hours")
        try:
            amount = int(form.get("every_amount") or 1)
        except (TypeError, ValueError):
            amount = 1
        raw["every"] = {unit: amount}
        # A timer with no start begins now. Said here rather than in the rule
        # module, which has no clock by design.
        raw.setdefault("start", datetime.now(tz=UTC).isoformat())

    elif repeat == REPEAT_CALENDAR:
        times = [t.strip() for t in str(form.get("times") or "09:00").split(",") if t.strip()]
        raw["at"] = {
            "weekdays": [int(d) for d in form.getlist("weekdays") if str(d).isdigit()],
            "times": times,
        }
        days = str(form.get("month_days") or "").strip()
        if days:
            raw["at"]["days"] = [int(d) for d in days.split(",") if d.strip().isdigit()]

    try:
        count = int(form.get("count") or 0)
    except (TypeError, ValueError):
        count = 0
    if count > 0:
        raw["count"] = count

    until = str(form.get("until") or "").strip()
    if until:
        raw["until"] = f"{until}T23:59:00"

    return raw


def _form_values(
    *, schedule: Schedule | None = None, compiled=None
) -> dict:
    """Everything `schedules/_form.html` renders, from whichever source there is.

    One dict for both pages, because they are the same fields: an existing row
    on the edit page, and what the compile proposed on the new one. The form
    reads only this, so what a model suggested is displayed through exactly the
    same path as what is stored -- there is no branch in the template that could
    show one of them differently.
    """
    if compiled is not None:
        values = _rule_defaults_from(compiled.rule)
        values.update(
            title=compiled.title, instruction=compiled.instruction, target=compiled.target
        )
        return values
    values = _rule_defaults_from((schedule.rule_json if schedule else {}) or {})
    values.update(
        title=schedule.title if schedule else "",
        instruction=schedule.instruction if schedule else "",
        target=schedule.target if schedule else TARGET_CHAT,
    )
    return values


def _rule_defaults_from(rule: dict) -> dict:
    """What the form should show for a rule.

    Derived from the *normalised* rule, so the form and the engine cannot
    disagree about what is stored -- an edit screen showing something other
    than what runs is the same failure as a label that names the wrong tool.
    Shared by the edit page and by the compile's review step, so what a model
    proposed is displayed through exactly the same path as what is saved.
    """
    rule = rule or {}
    at = rule.get("at") or {}
    every = rule.get("every") or {}
    if at:
        repeat = REPEAT_CALENDAR
    elif every:
        repeat = REPEAT_EVERY
    else:
        repeat = REPEAT_ONCE
    minutes = int(every.get("minutes") or 0)
    unit, amount = "minutes", minutes
    for size, name in ((10080, "weeks"), (1440, "days"), (60, "hours")):
        if minutes and not minutes % size:
            unit, amount = name, minutes // size
            break
    return {
        "repeat": repeat,
        "every_unit": unit,
        "every_amount": amount or 1,
        "weekdays": at.get("weekdays") or [],
        "times": ", ".join(at.get("times") or []),
        "month_days": ", ".join(str(d) for d in at.get("days") or []),
        "count": rule.get("count") or 0,
    }


def _context(db, user, schedule: Schedule | None, *, error: str = "") -> dict:
    return {
        "section": "scheduled",
        "schedule": schedule,
        "targets": OFFERED_TARGETS,
        "weekday_names": list(
            enumerate(("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"))
        ),
        "form": _form_values(schedule=schedule),
        "error": error,
        "models": chat_service.available_models(db, user),
        "timezone": clock.name_for(user) or str(clock.server_zone()),
        **sidebar_context(db, user),
    }


# --- The list ------------------------------------------------------------------
@router.get("/scheduled")
async def scheduled_list(request: Request, db: Db, user: RequiredUser):
    rows = list(
        db.scalars(schedules_service.visible(user).order_by(Schedule.created_at.desc()))
    )
    zone = clock.zone_for(user)
    return render(
        request,
        "schedules/index.html",
        {
            "section": "scheduled",
            "schedules": [
                {
                    "row": row,
                    "summary": rule_service.describe(row.rule_json or {}, zone=zone),
                    "next": clock.as_utc(row.next_fire_at).astimezone(zone)
                    if row.next_fire_at
                    else None,
                }
                for row in rows
            ],
            **sidebar_context(db, user),
        },
    )


@router.get("/scheduled/new")
async def new_schedule(request: Request, db: Db, user: RequiredUser, error: str = ""):
    """One question: what do you want to schedule?

    The detail comes from the compile. The manual form is on the same page
    behind a disclosure, so somebody who already knows exactly when it should
    run does not have to describe it in prose and hope.
    """
    return render(
        request,
        "schedules/new.html",
        {**_context(db, user, None, error=error), "compiled": None, "described": ""},
    )


@router.post("/api/schedules/describe")
async def describe_schedule(request: Request, db: Db, user: RequiredUser):
    """Work a plain-language request into a schedule, and show it back.

    Deliberately a *review* step rather than creating the schedule outright.
    The whole point of the compile is that a model chose the timing, and a
    timing nobody looked at is exactly the standing instruction this codebase
    refuses to create silently elsewhere.

    Nothing here can fail into an error page: a cleared fragment, an endpoint
    that is down, prose instead of JSON and a rule that means nothing all end at
    the same place, which is the form with the reader's own words in it and a
    line saying what to finish.
    """
    from lembas.services import prompts as prompts_service
    from lembas.services.schedule import compile as compile_service

    form = await request.form()
    described = str(form.get("request") or "").strip()

    template = prompts_service.resolve(db, "task.schedule_compile")
    resolved = compile_service.endpoint_for(db, user)
    if resolved is None:
        compiled = compile_service.Compiled(
            instruction=described,
            title=described[:80],
            reason="There is no model configured to work this out, so fill it in yourself.",
        )
    else:
        endpoint, model_id = resolved
        compiled = await compile_service.compile_request(
            endpoint, model_id, described, template=template, user=user
        )

    context = _context(db, user, None)
    # The compiled values become the form's values, so the reader edits what the
    # model proposed rather than being shown it beside an empty form.
    context["form"] = _form_values(compiled=compiled)
    return render(
        request,
        "schedules/new.html",
        {
            **context,
            "compiled": compiled,
            "described": described,
            "summary": rule_service.describe(compiled.rule, zone=clock.zone_for(user))
            if compiled.rule
            else "",
        },
    )


@router.get("/scheduled/{schedule_id}/edit")
async def edit_schedule(
    request: Request, db: Db, user: RequiredUser, schedule_id: str, error: str = ""
):
    schedule = schedules_service.get(db, schedule_id, user)
    if schedule is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
    return render(request, "schedules/edit.html", _context(db, user, schedule, error=error))


# --- Writing --------------------------------------------------------------------
@router.post("/api/schedules")
async def create_schedule(request: Request, db: Db, user: RequiredUser) -> Response:
    form = await request.form()
    try:
        schedule = schedules_service.create(
            db,
            owner=user,
            title=str(form.get("title") or ""),
            instruction=str(form.get("instruction") or ""),
            request=str(form.get("instruction") or ""),
            rule=_rule_from_form(form),
            target=str(form.get("target") or TARGET_CHAT),
            model_id=str(form.get("model_id") or ""),
        )
    except schedules_service.ScheduleError as error:
        # Back to the form with the reason, rather than a 400 nobody can act on.
        return RedirectResponse(
            f"/scheduled/new?error={error}", status_code=status.HTTP_303_SEE_OTHER
        )
    return RedirectResponse(f"/chat/{schedule.chat_id}", status_code=status.HTTP_303_SEE_OTHER)


@router.post("/api/schedules/{schedule_id}")
async def save_schedule(
    request: Request, db: Db, user: RequiredUser, schedule_id: str
) -> Response:
    schedule = schedules_service.get(db, schedule_id, user)
    if schedule is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
    form = await request.form()
    try:
        schedules_service.update(
            db,
            schedule,
            owner=user,
            title=str(form.get("title") or ""),
            instruction=str(form.get("instruction") or ""),
            rule=_rule_from_form(form),
            target=str(form.get("target") or TARGET_CHAT),
        )
    except schedules_service.ScheduleError as error:
        return RedirectResponse(
            f"/scheduled/{schedule_id}/edit?error={error}",
            status_code=status.HTTP_303_SEE_OTHER,
        )
    return RedirectResponse(f"/chat/{schedule.chat_id}", status_code=status.HTTP_303_SEE_OTHER)


@router.post("/api/schedules/{schedule_id}/toggle")
async def toggle_schedule(
    db: Db, user: RequiredUser, schedule_id: str, enabled: str = Form("")
) -> Response:
    schedule = schedules_service.get(db, schedule_id, user)
    if schedule is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
    schedules_service.set_enabled(
        db, schedule, owner=user, enabled=enabled not in ("", "0", "false")
    )
    return RedirectResponse(
        f"/chat/{schedule.chat_id}", status_code=status.HTTP_303_SEE_OTHER
    )


@router.post("/api/schedules/{schedule_id}/run")
async def run_schedule(db: Db, user: RequiredUser, schedule_id: str) -> Response:
    """Fire it now, without consuming the run it was scheduled for.

    `runner.run_now` is a different entry point from the ticker's for exactly
    that reason -- testing a schedule must not skip the real one.
    """
    schedule = schedules_service.get(db, schedule_id, user)
    if schedule is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
    chat_id = schedule.chat_id
    await runner.run_now(schedule_id)
    return RedirectResponse(f"/chat/{chat_id}", status_code=status.HTTP_303_SEE_OTHER)


@router.post("/api/schedules/{schedule_id}/delete")
async def delete_schedule(
    db: Db, user: RequiredUser, schedule_id: str, keep_chat: str = Form("1")
) -> Response:
    schedule = schedules_service.get(db, schedule_id, user)
    if schedule is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "That schedule is not available.")
    schedules_service.delete(db, schedule, keep_chat=keep_chat not in ("", "0", "false"))
    return RedirectResponse("/scheduled", status_code=status.HTTP_303_SEE_OTHER)
