from __future__ import annotations

import os
import traceback
from urllib.parse import quote
from datetime import date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo

from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from . import board, fleet, people, storage, timesheet, voice
from .pdf import (build_incident_pdf, build_signed_pdf, build_take5_pdf, build_tm_pdf, page_count,
                  qr_data_url, render_page_png)

TZ = ZoneInfo("Australia/Sydney")
HERE = Path(__file__).parent

app = FastAPI(title="LFCS Sign-On")
app.mount("/static", StaticFiles(directory=HERE / "static"), name="static")
templates = Jinja2Templates(directory=HERE / "templates")
# Cache-buster for JS/CSS. Phones cache static files hard; a stale take5.js/report.js on a phone = form
# that "won't submit". Newest mtime under static/ changes on every deploy, so the URL changes too.
STATIC_V = str(int(max(p.stat().st_mtime for p in (HERE / "static").iterdir())))
templates.env.globals["v"] = STATIC_V

store = storage.from_env()
BASE = os.getenv("PUBLIC_BASE_URL", "http://localhost:8080").rstrip("/")
# Timesheets live beside the jobs, not inside them — a timesheet belongs to the bloke, not the job.
crew_files = timesheet.CrewFiles(os.getenv("TIMESHEETS_ROOT", "I:/My Drive/Timesheets"))
# Devices and crew list — company-wide, because a bloke's phone is his on every job.
crew = people.People(store)
templates.env.globals["tel"] = people.tel_href


def _job_or_404(job_no: str) -> str:
    name = store.find_job(job_no)
    if not name:
        raise HTTPException(404, f"No job folder starting with {job_no}")
    return name


# --------------------------------------------------------------------------- who is holding the phone
#
# The token lives in the phone's localStorage; the cookie is only how it reaches us on each request
# (see static/device.js — localStorage is the copy that survives, the cookie is the wire). We store
# the SHA-256 of it and nothing else, so devices.csv sitting on Drive cannot be turned back into a
# working phone.


def _who(request: Request) -> people.Identity:
    return crew.identify(request.cookies.get(people.COOKIE, ""))


def _bind_cookie(response, token: str):
    # Not HttpOnly on purpose: device.js has to read it back to keep the localStorage copy in step.
    response.set_cookie(people.COOKIE, token, max_age=people.COOKIE_MAX_AGE, path="/", samesite="lax")
    return response


def _claim(response, name: str, note: str = "") -> str:
    """First time this phone says a name: mint a token, log the request, wait for a foreman."""
    token = people.new_token()
    crew.append_device(people.token_hash(token), name.strip(), "pending", "self", note)
    _bind_cookie(response, token)
    return token


def _gate(ident: people.Identity, name: str) -> str:
    """'' = let him through. 'office' = he is switched off. 'pending' = this phone is not his yet.

    Binding only bites once a bloke has an approved phone or has been switched off. Until then the
    old name-memory keeps working, so shipping this does not lock out a site whose foreman has not
    yet been told there is anything to tap.
    """
    if ident.inactive:
        return "office"
    p = crew.person(name)
    if p and (p.get("status") or "").strip().lower() == "inactive":
        return "office"
    if ident.bound:
        return ""
    if crew.enforced_for(name):
        return "pending"
    return ""


def _foreman_contact(job_no: str) -> tuple[str, str]:
    """Who the crew rings on THIS job. Foreman.txt beside Crew.txt; phone falls back to people.csv."""
    try:
        name, phone = store.get_foreman(job_no)
    except Exception:
        return "", ""
    if name and not phone:
        phone = crew.phone_of(name)
    return name, phone


def _blocked(request: Request, job_no: str, job_name: str, why: str, name: str = ""):
    f_name, f_phone = _foreman_contact(job_no)
    return templates.TemplateResponse(request, "blocked.html", {
        "job_no": job_no, "job_name": job_name, "why": why, "name": name,
        "foreman_name": f_name, "foreman_phone": f_phone,
    }, status_code=403)


def _crew_page(request: Request, job_no: str, typed: str):
    """Common opening move for every worker page: who is this, and does he still get a name box?"""
    ident = _who(request)
    if ident.bound:
        return ident, ident.name, True
    return ident, (typed or "").strip(), False


def _boss_only(request: Request, job_no: str, job_name: str):
    """Same rule as the foreman screen: open until the first foreman phone is bound, then his only.

    Returns (identity, response). A response means stop — hand it straight back to FastAPI.
    """
    ident = _who(request)
    if crew.boss_bound_anywhere() and not ident.is_boss:
        return ident, templates.TemplateResponse(request, "foreman_only.html", {
            "job_no": job_no, "job_name": job_name, "ident": ident}, status_code=403)
    return ident, None


def _pickable(job_no: str) -> list[str]:
    """Who a foreman can tap onto a docket: this job's crew first, then anyone else still active."""
    names = list(store.get_crew(job_no))
    seen = {people.norm(n) for n in names}
    for p in crew.people():
        n = (p.get("name") or "").strip()
        if n and people.norm(n) not in seen and (p.get("status") or "active").strip().lower() != "inactive":
            names.append(n)
            seen.add(people.norm(n))
    return names


@app.get("/", response_class=HTMLResponse)
def home(request: Request):
    return templates.TemplateResponse(request, "home.html", {})


def _job_day(job_no: str, when: date, now: datetime) -> list[tuple[str, timesheet.Day]]:
    """Everyone with a tap on this job on this day, and what the day adds up to for each.

    Runs the register through the same build_week as the timesheet, per bloke, so the foreman's
    screen and his sheet can never disagree about what a tap means.
    """
    by_name: dict[str, list[dict]] = {}
    for r in store.list_attendance(job_no):
        n = (r.get("name") or "").strip()
        if n:
            by_name.setdefault(n, []).append(r)
    out = []
    monday = timesheet.week_start(when)
    for n, evs in by_name.items():
        day = next((d for d in timesheet.build_week(evs, monday, now) if d.date == when), None)
        if day and (day.start or day.finish or day.adjusted):
            out.append((n, day))
    return sorted(out, key=lambda x: x[0].lower())


@app.get("/j/{job_no}", response_class=HTMLResponse)
def foreman(request: Request, job_no: str, d: str = ""):
    """The job on one screen: who's on, whose phone is waiting, the crew, and the day's times."""
    job_name = _job_or_404(job_no)
    ident = _who(request)
    # Open until the first foreman is bound — otherwise there is no phone left that could approve
    # the first phone, this one included.
    if crew.boss_bound_anywhere() and not ident.is_boss:
        return templates.TemplateResponse(request, "foreman_only.html", {
            "job_no": job_no, "job_name": job_name, "ident": ident}, status_code=403)

    now = datetime.now(TZ)
    try:
        when = date.fromisoformat(d.strip())
    except ValueError:
        when = now.date()

    docs = store.list_docs(job_no)
    # who has signed each doc (any date — a doc is a dated file, so "ever" == "for this issue")
    signed: dict[str, list[str]] = {}
    for r in store.list_register(job_no):
        signed.setdefault(r.get("doc", ""), []).append(r.get("name", ""))
    job_crew = store.get_crew(job_no)
    items = []
    for doc in docs:
        names = signed.get(doc.filename, [])
        seen, uniq = set(), []
        for n in names:
            if n and n not in seen:
                seen.add(n); uniq.append(n)
        items.append({"doc": doc, "url": f"{BASE}/sign/{job_no}/{doc.slot}", "qr": qr_data_url(f"{BASE}/sign/{job_no}/{doc.slot}"),
                      "pdf": f"/doc/{job_no}/{doc.slot}.pdf", "qr_full": f"/qr/{job_no}/{doc.slot}", "signed": uniq,
                      "missing": [c for c in job_crew if c not in seen]})
    take5_url = f"{BASE}/take5/{job_no}"
    report_url = f"{BASE}/report/{job_no}"
    on_url = f"{BASE}/on/{job_no}"
    open_tasks = [r for r in store.list_take5(job_no) if not r.get("finish")]

    # who's on now — and who has not shown a sign of life today
    day_rows = _job_day(job_no, when, now)
    seen_today = {n.lower() for n, _ in day_rows}
    on = [(n, x) for n, x in day_rows if x.on_now]
    off = [(n, x) for n, x in day_rows if not x.on_now]
    not_seen = [c for c in job_crew if c.lower() not in seen_today]

    devices = list(crew.latest_by_token().values())
    roster = sorted(crew.people(), key=lambda r: (r.get("name") or "").lower())
    approved = crew.approved_names()
    for r in roster:
        r["bound"] = people.norm(r.get("name", "")) in approved

    # T&M: the day's dockets, plus any docket from any day the super has not signed yet. An
    # unsigned docket is money sitting on the ground, so it stays on the screen until it is signed.
    day_iso = when.isoformat()
    tm_rows = _tm_rows(job_no)
    dockets = [r for r in tm_rows if r.get("date") == day_iso] + \
              [r for r in tm_rows if r.get("date") != day_iso and (r.get("status") or "") != "signed"]
    materials_url = f"{BASE}/materials/{job_no}"
    photo_url = f"{BASE}/photo/{job_no}"

    return templates.TemplateResponse(request, "foreman.html", {
        "job_no": job_no, "job_name": job_name, "items": items,
        "take5_url": take5_url, "take5_qr": qr_data_url(take5_url), "open_tasks": open_tasks,
        "report_url": report_url, "report_qr": qr_data_url(report_url),
        "on_url": on_url, "on_qr": qr_data_url(on_url),
        "materials_url": materials_url, "materials_qr": qr_data_url(materials_url),
        "photo_url": photo_url, "photo_qr": qr_data_url(photo_url),
        "when": when, "is_today": when == now.date(),
        "prev_d": (when - timedelta(days=1)).isoformat(), "next_d": (when + timedelta(days=1)).isoformat(),
        "on_now": on, "signed_off": off, "not_seen": not_seen,
        "pending": [r for r in devices if (r.get("status") or "") == "pending"],
        "knocked_back": [r for r in devices if (r.get("status") or "") == "revoked"][-6:],
        "roster": roster, "roles": people.ROLES, "lunches": timesheet.LUNCH_CHOICES,
        "ident": ident, "job_crew": job_crew,
        "dockets": dockets, "materials": _open_materials(job_no), "defects": _open_defects(),
        "labels": storage.MATERIAL_STATUS,
        # Only wave 3 passes these, so an older process rendering this template shows no dead links.
        "board_url": f"/board/{job_no}", "diary_url": f"/diary/{job_no}",
    })


# --------------------------------------------------------------------------- device binding
#
# A phone asks to be someone; a foreman says yes or no. Nothing is ever edited — a change of mind is
# a new row and the latest row for a token is the one that counts.


@app.post("/device/{job_no}/claim")
async def device_claim(request: Request, job_no: str):
    """'That's me' on a phone with no token yet. Mints one, logs a pending request, sets the cookie."""
    _job_or_404(job_no)
    form = await request.form()
    name = str(form.get("name", "")).strip()
    nxt = str(form.get("next", "")).strip() or f"/on/{job_no}"
    if not name:
        raise HTTPException(400, "Pick your name first")
    if not nxt.startswith("/"):
        nxt = f"/on/{job_no}"                       # never bounce a phone off-site
    sep = "&" if "?" in nxt else "?"
    resp = RedirectResponse(f"{nxt}{sep}name={quote(name)}", status_code=303)
    ident = _who(request)
    if not ident.thash:
        _claim(resp, name, note=f"job {job_no}")
    return resp


@app.post("/j/{job_no}/device")
async def device_decide(request: Request, job_no: str):
    """APPROVE binds this token to this bloke. NOT HIM kills it. Both are new rows, never edits."""
    _job_or_404(job_no)
    ident = _who(request)
    if crew.boss_bound_anywhere() and not ident.is_boss:
        raise HTTPException(403, "Foreman only")
    form = await request.form()
    thash = str(form.get("thash", "")).strip()
    name = str(form.get("name", "")).strip()
    action = str(form.get("action", "")).strip().lower()
    if not thash or action not in ("approve", "reject"):
        raise HTTPException(400, "Which phone, and yes or no?")
    by = ident.name or "foreman"
    if action == "approve":
        crew.append_device(thash, name, "approved", by, f"job {job_no}")
        if not crew.person(name):
            crew.upsert_person(name, role="worker", by=by)   # first sight of him — put him on the list
    else:
        crew.append_device(thash, name, "revoked", by, f"not him — job {job_no}")
    return RedirectResponse(f"/j/{job_no}#phones", status_code=303)


@app.post("/j/{job_no}/crew")
async def crew_add(request: Request, job_no: str):
    _job_or_404(job_no)
    ident = _who(request)
    if crew.boss_bound_anywhere() and not ident.is_boss:
        raise HTTPException(403, "Foreman only")
    form = await request.form()
    name = str(form.get("name", "")).strip()
    if not name:
        raise HTTPException(400, "Name is required")
    crew.upsert_person(name, role=str(form.get("role", "worker")).strip(),
                       phone=str(form.get("phone", "")).strip(), by=ident.name or "foreman")
    return RedirectResponse(f"/j/{job_no}#crew", status_code=303)


@app.post("/j/{job_no}/crew/toggle")
async def crew_toggle(request: Request, job_no: str):
    """One tap. Inactive kills every one of his phones and every route answers 'See the office.'"""
    _job_or_404(job_no)
    ident = _who(request)
    if crew.boss_bound_anywhere() and not ident.is_boss:
        raise HTTPException(403, "Foreman only")
    form = await request.form()
    name = str(form.get("name", "")).strip()
    p = crew.person(name)
    if not p:
        raise HTTPException(404, "Not on the crew list")
    now_active = (p.get("status") or "active").strip().lower() != "inactive"
    crew.set_status(name, "inactive" if now_active else "active", ident.name or "foreman")
    return RedirectResponse(f"/j/{job_no}#crew", status_code=303)


@app.post("/j/{job_no}/adjust")
async def adjust(request: Request, job_no: str):
    """Correct a man's day. The tap he made stays exactly where it is — this is a row on top of it."""
    job_name = _job_or_404(job_no)
    ident = _who(request)
    if crew.boss_bound_anywhere() and not ident.is_boss:
        raise HTTPException(403, "Foreman only")
    form = await request.form()
    name = str(form.get("name", "")).strip()
    reason = str(form.get("reason", "")).strip()
    try:
        when = date.fromisoformat(str(form.get("day", "")).strip())
    except ValueError:
        raise HTTPException(400, "Which day?")
    if not name or not reason:
        raise HTTPException(400, "Name and a reason are both required — the reason is the record")

    now = datetime.now(TZ)
    before = next((d for n, d in _job_day(job_no, when, now) if n == name), None)
    olds = {"start": before.start_text if before else "", "finish": before.finish_text if before else "",
            "lunch": before.lunch_text if before else ""}
    by = ident.name or "foreman"
    wrote = 0
    for fld in ("start", "finish", "lunch"):
        val = str(form.get(fld, "")).strip()
        if not val:
            continue
        row = {"timestamp": now.isoformat(timespec="seconds"), "job": job_name, "name": name,
               "event": "adjust", "lunch": "", "note": "", "source": "foreman",
               "day": when.isoformat(), "field": fld, "old": olds[fld], "new": val,
               "reason": reason, "by": by}
        store.append_attendance(job_no, row)
        crew_files.append_event(name, row)
        wrote += 1
    if not wrote:
        raise HTTPException(400, "Nothing to change — fill in a start, finish or lunch")

    # Rebuild his sheet so the corrected number is in the .xlsx the office opens, not just on screen.
    try:
        monday = timesheet.week_start(when)
        days = timesheet.build_week(crew_files.list_events(name), monday, now)
        crew_files.write_week(name, monday, timesheet.render_xlsx(name, monday, days))
    except Exception:
        traceback.print_exc()
    return RedirectResponse(f"/j/{job_no}?d={when.isoformat()}#times", status_code=303)


@app.get("/qr/{job_no}/{kind}", response_class=HTMLResponse)
def qr_full(request: Request, job_no: str, kind: str):
    """Tap a QR, get a white page and the biggest code the phone can draw. Sun beats a small QR."""
    job_name = _job_or_404(job_no)
    fixed = {"on": (f"{BASE}/on/{job_no}", "Sign on / sign off"),
             "take5": (f"{BASE}/take5/{job_no}", "Take 5"),
             "report": (f"{BASE}/report/{job_no}", "Report a hazard / near miss"),
             "materials": (f"{BASE}/materials/{job_no}", "Short of something?"),
             "photo": (f"{BASE}/photo/{job_no}", "Site photo")}
    if kind in fixed:
        url, title = fixed[kind]
    else:
        doc = next((x for x in store.list_docs(job_no) if x.slot == kind), None)
        if not doc:
            raise HTTPException(404, "Nothing to show a QR for")
        url, title = f"{BASE}/sign/{job_no}/{doc.slot}", doc.filename
    return templates.TemplateResponse(request, "qr.html", {
        "job_no": job_no, "job_name": job_name, "title": title, "url": url, "qr": qr_data_url(url)})


@app.get("/doc/{job_no}/{slot}.pdf")
def doc_pdf(job_no: str, slot: str):
    _job_or_404(job_no)
    got = store.get_doc(job_no, slot)
    if not got:
        raise HTTPException(404, "Document not found")
    doc, data = got
    return Response(data, media_type="application/pdf",
                    headers={"Content-Disposition": f'inline; filename="{doc.filename}"'})


@app.get("/doc/{job_no}/{slot}/page/{n}.png")
def doc_page_png(job_no: str, slot: str, n: int):
    """Page rendered as PNG so phones scroll it like a web page (iframe PDFs are flaky on iOS)."""
    _job_or_404(job_no)
    got = store.get_doc(job_no, slot)
    if not got:
        raise HTTPException(404, "Document not found")
    return Response(render_page_png(got[1], n), media_type="image/png",
                    headers={"Cache-Control": "public, max-age=300"})


@app.get("/sign/{job_no}/{slot}", response_class=HTMLResponse)
def sign_page(request: Request, job_no: str, slot: str):
    job_name = _job_or_404(job_no)
    ident = _who(request)
    if ident.inactive:
        return _blocked(request, job_no, job_name, "office", ident.name)
    got = store.get_doc(job_no, slot)
    if not got:
        raise HTTPException(404, "Document not found. Check the Docs folder.")
    doc, data = got
    return templates.TemplateResponse(request, "sign.html", {"job_no": job_no, "job_name": job_name, "doc": doc,
         "crew": store.get_crew(job_no), "pdf_url": f"/doc/{job_no}/{slot}.pdf",
         "pages": list(range(1, page_count(data) + 1)),
         "name": ident.name if ident.bound else "", "locked": ident.bound},
    )


@app.post("/sign/{job_no}/{slot}", response_class=HTMLResponse)
def sign_submit(request: Request, job_no: str, slot: str,
                name: str = Form(...), confirmed: str = Form(...), signature: str = Form(...)):
    job_name = _job_or_404(job_no)
    got = store.get_doc(job_no, slot)
    if not got:
        raise HTTPException(404, "Document not found")
    doc, original = got
    name = name.strip()
    ident = _who(request)
    if ident.bound:
        name = ident.name           # a bound phone IS him; a typed name cannot sign for someone else
    why = _gate(ident, name) if name else ""
    if why:
        return _blocked(request, job_no, job_name, why, name)
    if not name or confirmed != "yes" or not signature.startswith("data:image/png"):
        raise HTTPException(400, "Name, confirmation and signature are all required")

    now = datetime.now(TZ)
    ua = request.headers.get("user-agent", "")
    signed = build_signed_pdf(original, job=f"{job_no} {job_name}", doc_name=doc.filename, rev=doc.rev,
                              name=name, when=now, sig_data_url=signature, user_agent=ua)
    fname = storage.signed_filename(doc, name, now)
    where = store.put_signed(job_no, fname, signed)
    store.append_register(job_no, {
        "timestamp": now.isoformat(timespec="seconds"), "job": job_no, "doc": doc.filename,
        "rev": doc.rev, "name": name, "signed_file": fname, "user_agent": ua[:200],
    })
    # PRG: redirect so a refresh on the phone cannot double-sign
    resp = RedirectResponse(f"/done/{job_no}/{slot}?name={quote(name)}&t={quote(now.strftime('%d/%m/%Y %H:%M'))}",
                            status_code=303)
    # A phone that has never asked to be anyone just signed a prestart under a name. Log the
    # request so the foreman has something to tap — the signature already went in, this only
    # decides whether the NEXT one has a name box on it.
    if not ident.thash:
        _claim(resp, name, note=f"job {job_no}")
    return resp


# --------------------------------------------------------------------------- take-5


@app.get("/take5/{job_no}", response_class=HTMLResponse)
def take5_page(request: Request, job_no: str):
    """Worker's own phone. Name, task in his words, tick hazards, sign. Under 30 seconds."""
    job_name = _job_or_404(job_no)
    ident = _who(request)
    return templates.TemplateResponse(request, "take5.html", {
        "job_no": job_no, "job_name": job_name, "crew": store.get_crew(job_no),
        "hazards": list(enumerate(store.get_hazards(job_no))),
        "name": ident.name if ident.bound else "", "locked": ident.bound,
    })


@app.post("/take5/{job_no}", response_class=HTMLResponse)
async def take5_submit(request: Request, job_no: str):
    job_name = _job_or_404(job_no)
    form = await request.form()
    name = str(form.get("name", "")).strip()
    task = str(form.get("task", "")).strip()
    other = str(form.get("other", "")).strip()
    signature = str(form.get("signature", ""))
    picked = form.getlist("hz")
    ident = _who(request)
    if ident.bound:
        name = ident.name
    if name:
        why = _gate(ident, name)
        if why:
            return _blocked(request, job_no, job_name, why, name)
    if not name or not task or not signature.startswith("data:image/png"):
        raise HTTPException(400, "Name, task and signature are all required")
    all_hz = store.get_hazards(job_no)
    chosen = []
    for i in picked:
        try:
            chosen.append(all_hz[int(i)])
        except (ValueError, IndexError):
            pass
    now = datetime.now(TZ)
    ua = request.headers.get("user-agent", "")
    t5_id = now.strftime("%Y%m%d-%H%M%S") + "-" + storage.slot_of(name)[:12]
    pdf = build_take5_pdf(job=job_name, name=name, task=task, when=now, hazards=chosen,
                          other=other, sig_data_url=signature, user_agent=ua, take5_id=t5_id)
    safe = storage.signed_filename(storage.Doc("take5", "Take5.pdf", ""), name, now)
    store.put_take5(job_no, safe, pdf)
    store.append_take5(job_no, {
        "id": t5_id, "start": now.isoformat(timespec="seconds"), "job": job_no, "name": name, "task": task,
        "hazards": " ; ".join(h for h, _ in chosen), "controls": " ; ".join(c for _, c in chosen if c),
        "other": other, "signed_file": safe, "user_agent": ua[:200],
        "finish": "", "qty": "", "unit": "", "finished_by": "",
    })
    return RedirectResponse(f"/take5/{job_no}/done?name={quote(name)}&t={quote(now.strftime('%d/%m/%Y %H:%M'))}&task={quote(task[:80])}",
                            status_code=303)


@app.get("/take5/{job_no}/done", response_class=HTMLResponse)
def take5_done(request: Request, job_no: str, name: str = "", t: str = "", task: str = ""):
    _job_or_404(job_no)
    return templates.TemplateResponse(request, "take5_done.html", {"job_no": job_no, "name": name, "when": t, "task": task})


@app.post("/take5/{job_no}/{take5_id}/finish")
def take5_finish(request: Request, job_no: str, take5_id: str, qty: str = Form(""), unit: str = Form(""),
                 by: str = Form("foreman")):
    """Foreman taps Done on the open-tasks list. qty/unit optional — the desk turns start/finish/qty into rates.

    Foreman-only: closing a task off is the desk saying the work happened, and the qty on it is the
    number a rate gets built from. It was always meant to be his tap, and take-5 ids are guessable.
    """
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    by = ident.name or by
    now = datetime.now(TZ)
    ok = store.finish_take5(job_no, take5_id, {
        "finish": now.isoformat(timespec="seconds"), "qty": qty.strip(), "unit": unit.strip(), "finished_by": by.strip(),
    })
    if not ok:
        raise HTTPException(404, "Take-5 record not found")
    return RedirectResponse(f"/j/{job_no}#tasks", status_code=303)


# --------------------------------------------------------------------------- hazard / near-miss / incident


@app.get("/report/{job_no}", response_class=HTMLResponse)
def report_page(request: Request, job_no: str):
    """One form, three kinds. Words + photo. Under a minute. If someone is hurt: first aid + phone call first."""
    job_name = _job_or_404(job_no)
    ident = _who(request)
    return templates.TemplateResponse(request, "report.html", {
        "job_no": job_no, "job_name": job_name, "crew": store.get_crew(job_no), "kinds": storage.INCIDENT_KINDS,
        "name": ident.name if ident.bound else "", "locked": ident.bound,
    })


@app.post("/report/{job_no}", response_class=HTMLResponse)
async def report_submit(request: Request, job_no: str):
    job_name = _job_or_404(job_no)
    form = await request.form()
    kind = str(form.get("kind", "")).strip()
    name = str(form.get("name", "")).strip()
    what = str(form.get("what", "")).strip()
    where = str(form.get("where", "")).strip()
    who = str(form.get("who", "")).strip()
    action = str(form.get("action", "")).strip()
    signature = str(form.get("signature", "") or "")
    ident = _who(request)
    if ident.bound:
        name = ident.name
    if name:
        why = _gate(ident, name)
        if why:
            return _blocked(request, job_no, job_name, why, name)
    if kind not in storage.INCIDENT_KINDS or not name or not what:
        raise HTTPException(400, "Type, name and what happened are required")
    photo = form.get("photo")
    photo_bytes = None
    if photo is not None and getattr(photo, "filename", ""):
        raw = await photo.read()
        if raw:
            photo_bytes = _to_jpeg(raw)
    now = datetime.now(TZ)
    ua = request.headers.get("user-agent", "")
    rid = now.strftime("%Y%m%d-%H%M%S") + "-" + kind
    stem = f"{now:%Y-%m-%d_%H%M}_{kind}_{storage.slot_of(name)[:16]}"
    photo_file = ""
    if photo_bytes:
        photo_file = stem + ".jpg"
        store.put_incident_file(job_no, photo_file, photo_bytes)
    pdf = build_incident_pdf(job=job_name, kind_label=storage.INCIDENT_KINDS[kind], name=name, what=what, where=where,
                             who=who, action=action, when=now, photo_jpeg=photo_bytes, sig_data_url=signature,
                             user_agent=ua, report_id=rid)
    pdf_file = stem + ".pdf"
    store.put_incident_file(job_no, pdf_file, pdf)
    store.append_incident(job_no, {
        "id": rid, "timestamp": now.isoformat(timespec="seconds"), "job": job_no, "kind": kind, "name": name,
        "what": what, "where": where, "who": who, "action": action, "photo_file": photo_file, "pdf_file": pdf_file,
        "user_agent": ua[:200], "followup": "", "toolbox_date": "",
    })
    return RedirectResponse(f"/report/{job_no}/done?kind={kind}&t={quote(now.strftime('%d/%m/%Y %H:%M'))}", status_code=303)


@app.get("/report/{job_no}/done", response_class=HTMLResponse)
def report_done(request: Request, job_no: str, kind: str = "", t: str = ""):
    _job_or_404(job_no)
    return templates.TemplateResponse(request, "report_done.html", {
        "job_no": job_no, "kind": kind, "kind_label": storage.INCIDENT_KINDS.get(kind, ""), "when": t})


def _to_jpeg(raw: bytes, max_px: int = 1600) -> bytes:
    """Phone photos are 3-8 MB HEIC/JPEG. Downsize + re-encode so the PDF and Drive stay sane."""
    from io import BytesIO
    from PIL import Image, ImageOps
    try:
        img = Image.open(BytesIO(raw))
        img = ImageOps.exif_transpose(img).convert("RGB")
        img.thumbnail((max_px, max_px))
        out = BytesIO()
        img.save(out, format="JPEG", quality=82, optimize=True)
        return out.getvalue()
    except Exception:
        return raw


@app.get("/incident/{job_no}/{filename}")
def incident_photo(request: Request, job_no: str, filename: str):
    """The photo off a hazard/near-miss report, for the diary. Foreman only.

    An incident photo can have somebody's injury in it, or somebody else's mistake. It shows on the
    diary because that is where it belongs in evidence, and the diary is already foreman-only.
    """
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    parts = tuple(store.signon_parts[:-1]) + (storage.INCIDENTS,)
    data = store.get_job_file(job_no, parts, filename)
    if data is None:
        raise HTTPException(404, "Photo not found")
    return Response(data, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=300"})


@app.get("/prestart/{job_no}")
def prestart_latest(job_no: str):
    """Stable link for the WhatsApp pin: always opens the newest prestart in Docs/ (they are dated files)."""
    _job_or_404(job_no)
    docs = [d for d in store.list_docs(job_no) if "pre-start" in d.filename.lower() or "prestart" in d.filename.lower()]
    if not docs:
        raise HTTPException(404, "No prestart up yet for this job. Ask the supervisor.")
    # filenames end "DD-MM-YYYY.pdf" — sort by that date, newest first; fall back to name order
    import re
    def key(d):
        m = re.search(r"(\d{2})-(\d{2})-(\d{4})", d.filename)
        return (m.group(3), m.group(2), m.group(1)) if m else ("", "", d.filename)
    newest = sorted(docs, key=key, reverse=True)[0]
    return RedirectResponse(f"/sign/{job_no}/{newest.slot}", status_code=302)


# --------------------------------------------------------------------------- sign on / sign off -> timesheet
#
# Two taps is the whole ask of a worker. Everything else is derived. The events are the presence
# record (the muster) and are never edited; hours come out of them in app/timesheet.py.


def _monday(w: str) -> date:
    """?w=YYYY-MM-DD picks a week. Anything else — including nothing — is this week."""
    try:
        return timesheet.week_start(date.fromisoformat(w.strip()))
    except ValueError:
        return timesheet.week_start(datetime.now(TZ).date())


@app.get("/on/{job_no}", response_class=HTMLResponse)
def signon_page(request: Request, job_no: str, name: str = "", flash: str = "", via: str = ""):
    """The worker's own phone. Signed off -> one button. Signed on -> lunch is already 0:30, one button."""
    job_name = _job_or_404(job_no)
    ident, name, locked = _crew_page(request, job_no, name)
    now = datetime.now(TZ)
    events = crew_files.list_events(name) if name else []
    since = timesheet.open_since(events, now)
    days = timesheet.build_week(events, timesheet.week_start(now.date()), now) if name else []
    today = next((d for d in days if d.is_today), None)
    f_name, f_phone = _foreman_contact(job_no)
    wait = _gate(ident, name) if name else ""
    resp = templates.TemplateResponse(request, "on.html", {
        "job_no": job_no, "job_name": job_name, "name": name, "crew": store.get_crew(job_no),
        "since": since, "since_text": timesheet.fmt_ampm(since) if since else "",
        "since_iso": since.isoformat() if since else "",
        "now_text": timesheet.fmt_ampm(now), "flash": flash,
        "today_hours": today.hours_text if today else "", "week_hours": timesheet.num(timesheet.week_hours(days)) if days else 0,
        "lunches": timesheet.LUNCH_CHOICES, "default_lunch": timesheet.DEFAULT_LUNCH,
        "locked": locked, "wait": wait, "foreman_name": f_name, "foreman_phone": f_phone,
        # Routed here by /today off the roster — so give him the one line that fixes a wrong roster.
        "via": via, "tomorrow_job": _tomorrow_job(name),
    })
    # A phone that has never asked to be anyone, arriving with a name (the pick-list, or the old
    # name-memory replaying it) — log the request so the foreman has something to tap.
    if name and not ident.thash:
        _claim(resp, name, note=f"job {job_no}")
    return resp


@app.post("/on/{job_no}")
async def signon_submit(request: Request, job_no: str):
    job_name = _job_or_404(job_no)
    form = await request.form()
    name = str(form.get("name", "")).strip()
    action = str(form.get("action", "")).strip().lower()
    lunch = str(form.get("lunch", timesheet.DEFAULT_LUNCH)).strip()
    note = str(form.get("note", "")).strip()
    ident = _who(request)
    if ident.bound:
        name = ident.name                       # a bound phone IS him; a typed name cannot override it
    if not name or action not in ("on", "off"):
        raise HTTPException(400, "Name and on/off are both required")
    why = _gate(ident, name)
    if why:
        return _blocked(request, job_no, job_name, why, name)
    if lunch not in timesheet.LUNCH_CHOICES:
        lunch = timesheet.DEFAULT_LUNCH

    now = datetime.now(TZ)
    since = timesheet.open_since(crew_files.list_events(name), now)
    # A second tap of the same button — fat fingers, or a refresh — is not a second event.
    if action == "on" and since:
        return RedirectResponse(f"/on/{job_no}?name={quote(name)}&flash=already-on", status_code=303)
    if action == "off" and not since:
        return RedirectResponse(f"/on/{job_no}?name={quote(name)}&flash=already-off", status_code=303)

    row = {"timestamp": now.isoformat(timespec="seconds"), "job": job_name, "name": name, "event": action,
           "lunch": lunch if action == "off" else "", "note": note, "source": "worker"}
    store.append_attendance(job_no, row)     # the job's muster — who was on site
    crew_files.append_event(name, row)       # the worker's own week — feeds his timesheet

    flash = action
    if action == "off":
        # Rebuild this week's sheet from the events as they now stand. Cheap and idempotent.
        monday = timesheet.week_start(now.date())
        try:
            days = timesheet.build_week(crew_files.list_events(name), monday, now)
            crew_files.write_week(name, monday, timesheet.render_xlsx(name, monday, days))
        except Exception:
            # His hours ARE recorded — only the .xlsx render failed. Say so, never swallow it.
            traceback.print_exc()
            flash = "off-nosheet"
    return RedirectResponse(f"/week/{job_no}?name={quote(name)}&flash={flash}", status_code=303)


@app.get("/week/{job_no}", response_class=HTMLResponse)
def week_page(request: Request, job_no: str, name: str = "", w: str = "", flash: str = ""):
    """My week — the real LFCS timesheet, filling itself in. His own week only, never anyone else's."""
    job_name = _job_or_404(job_no)
    ident, name, locked = _crew_page(request, job_no, name)
    now = datetime.now(TZ)
    monday = _monday(w)
    events = crew_files.list_events(name) if name else []
    days = timesheet.build_week(events, monday, now)
    f_name, f_phone = _foreman_contact(job_no)
    resp = templates.TemplateResponse(request, "week.html", {
        "job_no": job_no, "job_name": job_name, "name": name, "crew": store.get_crew(job_no),
        "days": days, "monday": monday, "friday": timesheet.week_ending(monday),
        "total": timesheet.num(timesheet.week_hours(days)),
        "this_week": monday == timesheet.week_start(now.date()),
        "prev_w": (monday - timedelta(days=7)).isoformat(),
        "next_w": (monday + timedelta(days=7)).isoformat(),
        "w": monday.isoformat(), "flash": flash,
        "on_now": timesheet.open_since(events, now) is not None,
        "locked": locked, "foreman_name": f_name, "foreman_phone": f_phone,
        "tomorrow_job": _tomorrow_job(name),
    })
    if name and not ident.thash:
        _claim(resp, name, note=f"job {job_no}")
    return resp


@app.get("/week/{job_no}/xlsx")
def week_xlsx(request: Request, job_no: str, name: str = "", w: str = ""):
    """The same sheet the office gets, on his phone. His own week only — never another man's.

    A worker must not see another worker's hours, and until now the name was just a query
    parameter anyone could retype. Three states, because the site is mid-rollout:

      bound worker  - his own name or nothing
      foreman/admin - anyone, same as the foreman screen he already has
      unbound       - allowed, because Rocky is solo-trialling on a phone that has never claimed
                      a name and locking him out would kill the trial. But NOT for a bloke who
                      already has an approved phone: once binding is live for a man, his sheet
                      comes off his phone or it does not come at all.
    """
    _job_or_404(job_no)
    name = name.strip()
    if not name:
        raise HTTPException(400, "Open My week and pick your name first.")
    ident = _who(request)
    if ident.bound and not ident.is_boss and people.norm(name) != people.norm(ident.name):
        raise HTTPException(403, "That is not your timesheet.")
    if not ident.bound and crew.enforced_for(name):
        raise HTTPException(403, "Open My week on your own phone.")
    monday = _monday(w)
    days = timesheet.build_week(crew_files.list_events(name), monday)
    data = timesheet.render_xlsx(name, monday, days)
    return Response(data, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                    headers={"Content-Disposition": f'attachment; filename="{timesheet.week_filename(name, monday)}"'})


@app.get("/done/{job_no}/{slot}", response_class=HTMLResponse)
def done(request: Request, job_no: str, slot: str, name: str = "", t: str = ""):
    _job_or_404(job_no)
    got = store.get_doc(job_no, slot)
    doc = got[0] if got else storage.Doc(slot, slot, "")
    return templates.TemplateResponse(request, "done.html", {"job_no": job_no, "doc": doc, "name": name, "when": t})


# --------------------------------------------------------------------------- T&M docket
#
# The money one. A foreman raises it on his phone while the work is fresh, hands the phone to the
# head contractor's super, and the super signs it there and then. A docket signed on the day is a
# claim; a docket written up on Friday is an argument.
#
# Two steps with a saved draft in between, because the super is not always standing there when the
# work finishes. The draft survives, red on the foreman screen, until it is signed.

TM = storage.TM_PARTS
# Half-hour steps to ten. Chips, not a number pad — a number pad on a phone in the rain gets 45
# typed into a field that wanted 4.5, and nobody notices until the claim goes in.
TM_HOURS = [f"{0.5 * i:g}" for i in range(1, 21)]


def _tm_rows(job_no: str) -> list[dict]:
    return store.read_job_csv(job_no, TM, storage.TM_REGISTER)


def _tm_find(job_no: str, docket_id: str) -> dict | None:
    return next((r for r in _tm_rows(job_no) if (r.get("id") or "") == docket_id), None)


def _tm_men(row: dict) -> list[tuple[str, float]]:
    """'Dave Nguyen 4.5; Matty Bell 6' back into pairs. Plain text in the CSV on purpose."""
    out = []
    for part in (row.get("hours") or "").split(";"):
        part = part.strip()
        if not part:
            continue
        name, _, hrs = part.rpartition(" ")
        try:
            out.append((name.strip(), float(hrs)))
        except ValueError:
            out.append((part, 0.0))
    return out


def _mark_dayworks(job_no: str, job_name: str, name: str, day: str, docket_id: str, by: str, why: str) -> None:
    """He was on a signed docket that day, so Day Works on his timesheet says Y.

    Written as an event beside his taps rather than stamped onto a rendered sheet, so any later
    rebuild picks it up on its own. It never touches his hours — the Y/N column only.
    """
    now = datetime.now(TZ)
    row = {"timestamp": now.isoformat(timespec="seconds"), "job": job_name, "name": name,
           "event": "dayworks", "lunch": "", "note": docket_id, "source": "foreman",
           "day": day, "field": "dayworks", "old": "", "new": "Y", "reason": why[:100], "by": by}
    store.append_attendance(job_no, row)
    crew_files.append_event(name, row)
    try:
        monday = timesheet.week_start(date.fromisoformat(day))
        days = timesheet.build_week(crew_files.list_events(name), monday, now)
        crew_files.write_week(name, monday, timesheet.render_xlsx(name, monday, days))
    except Exception:
        # The docket is signed and the event is recorded either way — only the .xlsx render failed.
        traceback.print_exc()


@app.get("/tm/{job_no}/new", response_class=HTMLResponse)
def tm_new(request: Request, job_no: str):
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    return templates.TemplateResponse(request, "tm_new.html", {
        "job_no": job_no, "job_name": job_name, "crew": _pickable(job_no),
        "hours": TM_HOURS, "today": datetime.now(TZ).date(), "ident": ident,
    })


@app.post("/tm/{job_no}/new")
async def tm_create(request: Request, job_no: str):
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    form = await request.form()
    description = str(form.get("description", "")).strip()
    plant = str(form.get("plant", "")).strip()
    men = [str(m).strip() for m in form.getlist("men") if str(m).strip()]
    if not description or not men:
        raise HTTPException(400, "A docket needs the work described and at least one man on it")
    pairs: list[tuple[str, float]] = []
    for m in men:
        try:
            h = float(str(form.get("hours|" + m, "")).strip() or 0)
        except ValueError:
            h = 0.0
        if h <= 0:
            raise HTTPException(400, "No hours on " + m)
        pairs.append((m, h))

    now = datetime.now(TZ)
    day = now.date().isoformat()
    seq = "%02d" % (sum(1 for r in _tm_rows(job_no) if (r.get("date") or "") == day) + 1)
    docket_id = day + "-" + seq
    store.append_job_csv(job_no, TM, storage.TM_REGISTER, storage.TM_HEADER, {
        "id": docket_id, "timestamp": now.isoformat(timespec="seconds"), "job": job_no, "date": day,
        "seq": seq, "description": description,
        "men": "; ".join(m for m, _ in pairs),
        "hours": "; ".join(f"{m} {h:g}" for m, h in pairs),
        "hours_total": f"{sum(h for _, h in pairs):g}",
        "plant": plant, "raised_by": ident.name or "foreman",
        "signed_by": "", "company": "", "signed_at": "", "pdf_file": "", "status": "draft",
    })
    return RedirectResponse(f"/tm/{job_no}/{docket_id}/sign", status_code=303)


@app.get("/tm/{job_no}/{docket_id}/sign", response_class=HTMLResponse)
def tm_sign_page(request: Request, job_no: str, docket_id: str):
    """Hand the phone over. The super's name and company get typed once, then he signs."""
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    rows = _tm_rows(job_no)
    row = next((r for r in rows if (r.get("id") or "") == docket_id), None)
    if not row:
        raise HTTPException(404, "No such docket")
    # The same super signs most weeks — offer whoever has signed on this job before, then the type-in.
    reps, seen = [], set()
    for r in rows:
        who, co = (r.get("signed_by") or "").strip(), (r.get("company") or "").strip()
        if who and who.lower() not in seen:
            seen.add(who.lower())
            reps.append((who, co))
    return templates.TemplateResponse(request, "tm_sign.html", {
        "job_no": job_no, "job_name": job_name, "row": row, "men": _tm_men(row), "reps": reps,
    })


@app.post("/tm/{job_no}/{docket_id}/sign")
async def tm_sign(request: Request, job_no: str, docket_id: str):
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    rows = _tm_rows(job_no)
    row = next((r for r in rows if (r.get("id") or "") == docket_id), None)
    if not row:
        raise HTTPException(404, "No such docket")
    if (row.get("status") or "") == "signed":
        return RedirectResponse(f"/tm/{job_no}/{docket_id}/done", status_code=303)   # refresh, not a re-sign
    form = await request.form()
    signed_by = str(form.get("signed_by", "")).strip()
    company = str(form.get("company", "")).strip()
    signature = str(form.get("signature", ""))
    if not signed_by or not signature.startswith("data:image/png"):
        raise HTTPException(400, "The name of whoever is signing and a signature are both required")

    now = datetime.now(TZ)
    men = _tm_men(row)
    try:
        worked = date.fromisoformat(row.get("date", ""))
    except ValueError:
        worked = now.date()
    docket_no = f"TM-{job_no}-{row['date']}-{row['seq']}"
    pdf = build_tm_pdf(job=f"{job_no} {job_name}", docket_no=docket_no,
                       when=datetime.combine(worked, now.timetz()), description=row.get("description", ""),
                       men=men, plant=row.get("plant", ""), raised_by=row.get("raised_by", ""),
                       signed_by=signed_by, company=company, signed_at=now, sig_data_url=signature,
                       user_agent=request.headers.get("user-agent", ""))
    fname = docket_no + ".pdf"
    store.put_job_file(job_no, TM, fname, pdf)
    row.update({"signed_by": signed_by, "company": company, "signed_at": now.isoformat(timespec="seconds"),
                "pdf_file": fname, "status": "signed"})
    store.write_job_csv(job_no, TM, storage.TM_REGISTER, storage.TM_HEADER, rows)

    by = ident.name or row.get("raised_by", "") or "foreman"
    for name, _h in men:
        _mark_dayworks(job_no, job_name, name, row.get("date", ""), docket_id, by, row.get("description", ""))
    return RedirectResponse(f"/tm/{job_no}/{docket_id}/done", status_code=303)


@app.get("/tm/{job_no}/{docket_id}/done", response_class=HTMLResponse)
def tm_done(request: Request, job_no: str, docket_id: str):
    # Foreman only, like every other docket screen: a docket carries other men's hours on it, and a
    # worker never sees another man's hours. Docket ids are guessable (<date>-01), so this matters.
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    row = _tm_find(job_no, docket_id)
    if not row:
        raise HTTPException(404, "No such docket")
    return templates.TemplateResponse(request, "tm_done.html", {
        "job_no": job_no, "job_name": job_name, "row": row, "men": _tm_men(row)})


@app.get("/tm/{job_no}/{docket_id}.pdf")
def tm_pdf(request: Request, job_no: str, docket_id: str):
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    row = _tm_find(job_no, docket_id)
    if not row or not row.get("pdf_file"):
        raise HTTPException(404, "That docket has not been signed yet, so there is no PDF")
    data = store.get_job_file(job_no, TM, row["pdf_file"])
    if data is None:
        raise HTTPException(404, "Docket PDF missing from the job folder")
    return Response(data, media_type="application/pdf",
                    headers={"Content-Disposition": 'inline; filename="' + row["pdf_file"] + '"'})


# --------------------------------------------------------------------------- materials
#
# A request list, not stock control. One box: what are you short of. The foreman answers with one of
# three taps and the bloke who asked sees the answer on the same screen he asked from.


def _materials(job_no: str) -> list[dict]:
    return store.read_job_csv(job_no, store.signon_parts, storage.MATERIALS_REGISTER)


def _open_materials(job_no: str) -> list[dict]:
    return [r for r in _materials(job_no) if (r.get("status") or "open") == "open"]


@app.get("/materials/{job_no}", response_class=HTMLResponse)
def materials_page(request: Request, job_no: str, name: str = "", flash: str = ""):
    job_name = _job_or_404(job_no)
    ident, name, locked = _crew_page(request, job_no, name)
    wait = _gate(ident, name) if name else ""
    mine = []
    if name:
        n = people.norm(name)
        mine = [r for r in _materials(job_no) if people.norm(r.get("name", "")) == n][-8:]
    f_name, f_phone = _foreman_contact(job_no)
    resp = templates.TemplateResponse(request, "materials.html", {
        "job_no": job_no, "job_name": job_name, "name": name, "locked": locked, "wait": wait,
        "crew": store.get_crew(job_no), "mine": list(reversed(mine)), "flash": flash,
        "labels": storage.MATERIAL_STATUS, "foreman_name": f_name, "foreman_phone": f_phone,
    })
    if name and not ident.thash:
        _claim(resp, name, note=f"job {job_no}")
    return resp


@app.post("/materials/{job_no}")
async def materials_submit(request: Request, job_no: str):
    job_name = _job_or_404(job_no)
    form = await request.form()
    name = str(form.get("name", "")).strip()
    item = str(form.get("item", "")).strip()
    ident = _who(request)
    if ident.bound:
        name = ident.name
    if name:
        why = _gate(ident, name)
        if why:
            return _blocked(request, job_no, job_name, why, name)
    if not name or not item:
        raise HTTPException(400, "Your name and what you are short of are both required")

    now = datetime.now(TZ)
    mid = now.strftime("%Y%m%d-%H%M%S")
    photo_file = ""
    photo = form.get("photo")
    if photo is not None and getattr(photo, "filename", ""):
        raw = await photo.read()
        if raw:
            photo_file = mid + "_" + timesheet.safe_name(name)[:16] + ".jpg"
            store.put_job_file(job_no, store.signon_parts + (storage.MATERIALS_DIR,), photo_file, _to_jpeg(raw))
    store.append_job_csv(job_no, store.signon_parts, storage.MATERIALS_REGISTER, storage.MATERIALS_HEADER, {
        "id": mid, "timestamp": now.isoformat(timespec="seconds"), "job": job_no, "name": name,
        "item": item, "photo": photo_file, "status": "open", "by": "", "note": "",
    })
    return RedirectResponse(f"/materials/{job_no}?name={quote(name)}&flash=sent", status_code=303)


@app.get("/materials/{job_no}/photo/{filename}")
def materials_photo(job_no: str, filename: str):
    _job_or_404(job_no)
    data = store.get_job_file(job_no, store.signon_parts + (storage.MATERIALS_DIR,), filename)
    if data is None:
        raise HTTPException(404, "Photo not found")
    return Response(data, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=300"})


@app.post("/j/{job_no}/material")
async def material_decide(request: Request, job_no: str):
    """Three taps and no fourth. 'Order it' marks it ordered — the foreman rings the supplier."""
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    form = await request.form()
    mid = str(form.get("id", "")).strip()
    action = str(form.get("action", "")).strip().lower()
    if action not in storage.MATERIAL_STATUS:
        raise HTTPException(400, "Got it, order it or not needed")
    rows = _materials(job_no)
    hit = False
    for r in rows:
        if (r.get("id") or "") == mid:
            r["status"] = action
            r["by"] = ident.name or "foreman"
            r["note"] = datetime.now(TZ).isoformat(timespec="seconds")
            hit = True
    if not hit:
        raise HTTPException(404, "No such request")
    store.write_job_csv(job_no, store.signon_parts, storage.MATERIALS_REGISTER, storage.MATERIALS_HEADER, rows)
    return RedirectResponse(f"/j/{job_no}#materials", status_code=303)


# --------------------------------------------------------------------------- photo drop
#
# One button. The camera opens, the shot lands in the job's site diary under today's date, named
# time and who took it. A web page can never reach a camera roll — so the button IS the filing, and
# that is the point: it kills the WhatsApp-then-file-it-later chore.


@app.get("/photo/{job_no}", response_class=HTMLResponse)
def photo_page(request: Request, job_no: str, name: str = "", saved: str = "", day: str = ""):
    job_name = _job_or_404(job_no)
    ident, name, locked = _crew_page(request, job_no, name)
    wait = _gate(ident, name) if name else ""
    resp = templates.TemplateResponse(request, "photo.html", {
        "job_no": job_no, "job_name": job_name, "name": name, "locked": locked, "wait": wait,
        "crew": store.get_crew(job_no), "saved": saved, "day": day,
    })
    if name and not ident.thash:
        _claim(resp, name, note=f"job {job_no}")
    return resp


@app.post("/photo/{job_no}")
async def photo_submit(request: Request, job_no: str):
    job_name = _job_or_404(job_no)
    form = await request.form()
    name = str(form.get("name", "")).strip()
    ident = _who(request)
    if ident.bound:
        name = ident.name
    if name:
        why = _gate(ident, name)
        if why:
            return _blocked(request, job_no, job_name, why, name)
    if not name:
        raise HTTPException(400, "Pick your name first")
    photo = form.get("photo")
    raw = await photo.read() if photo is not None and getattr(photo, "filename", "") else b""
    if not raw:
        raise HTTPException(400, "No photo came through. Try again.")

    now = datetime.now(TZ)
    day = now.strftime("%Y-%m-%d")
    who = timesheet.safe_name(name) or "site"
    parts = storage.DIARY_PARTS + (day,)
    fname = f"{now:%H-%M} {who}.jpg"
    if store.get_job_file(job_no, parts, fname) is not None:
        fname = f"{now:%H-%M-%S} {who}.jpg"      # two in the same minute — keep both, never overwrite
    store.put_job_file(job_no, parts, fname, _to_jpeg(raw))
    return RedirectResponse(f"/photo/{job_no}?name={quote(name)}&day={day}&saved={quote(fname)}", status_code=303)


@app.get("/photo/{job_no}/{day}/{filename}")
def photo_file(job_no: str, day: str, filename: str):
    _job_or_404(job_no)
    data = store.get_job_file(job_no, storage.DIARY_PARTS + (day,), filename)
    if data is None:
        raise HTTPException(404, "Photo not found")
    return Response(data, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=300"})


# --------------------------------------------------------------------------- vehicle prestart
#
# No device binding here on purpose. A bloke borrows a ute for a run to the yard and he still has to
# check it — a gate that says "see the office" at 6am in a car park just means the check does not
# happen. Company plant, so the record is company-level, and a defect shows on EVERY foreman screen
# until someone marks it fixed: the truck is not any one job's problem.

FLEET_SUB = (storage.FLEET_DIR,)


def _open_defects() -> list[dict]:
    return [r for r in store.read_company_csv(storage.DEFECTS_REGISTER, subdir=FLEET_SUB)
            if (r.get("status") or "") == "open"]


@app.get("/plant", response_class=HTMLResponse)
def plant_index(request: Request):
    counts: dict[str, int] = {}
    for r in _open_defects():
        counts[r.get("rego", "")] = counts.get(r.get("rego", ""), 0) + 1
    return templates.TemplateResponse(request, "plant_index.html", {
        "vehicles": [dict(v, rego=k, defects=counts.get(k, 0)) for k, v in fleet.VEHICLES.items()]})


@app.get("/plant/{rego}", response_class=HTMLResponse)
def plant_page(request: Request, rego: str, flash: str = ""):
    v = fleet.vehicle(rego)
    if not v:
        raise HTTPException(404, "That rego is not on the LFCS fleet list")
    ident = _who(request)
    roster = [p.get("name", "") for p in crew.people()
              if (p.get("status") or "active").strip().lower() != "inactive" and p.get("name")]
    return templates.TemplateResponse(request, "plant.html", {
        "v": v, "checks": fleet.checks_for(rego), "crew": sorted(roster, key=str.lower),
        "name": ident.name if ident.bound else "", "locked": ident.bound, "flash": flash,
        "defects": [r for r in _open_defects() if r.get("rego") == v["rego"]],
    })


@app.post("/plant/{rego}")
async def plant_submit(request: Request, rego: str):
    v = fleet.vehicle(rego)
    if not v:
        raise HTTPException(404, "That rego is not on the LFCS fleet list")
    form = await request.form()
    ident = _who(request)
    name = ident.name if ident.bound else str(form.get("name", "")).strip()
    note = str(form.get("note", "")).strip()
    if not name:
        raise HTTPException(400, "Pick your name first")

    checks = fleet.checks_for(rego)
    answered, fails = 0, []
    for key, label, _hint in checks:
        val = str(form.get("c|" + key, "")).strip().lower()
        if val in ("ok", "no"):
            answered += 1
        if val == "no":
            fails.append(label)
    if answered < len(checks):
        raise HTTPException(400, "Every line needs an OK or a NO before it counts as a check")
    if fails and not note:
        raise HTTPException(400, "Say what is wrong with it — that note is the whole point of the NO")

    now = datetime.now(TZ)
    pid = now.strftime("%Y%m%d-%H%M%S")
    photo_file = ""
    photo = form.get("photo")
    if photo is not None and getattr(photo, "filename", ""):
        raw = await photo.read()
        if raw:
            photo_file = pid + "_" + v["rego"] + ".jpg"
            store.put_company_file(photo_file, _to_jpeg(raw), subdir=FLEET_SUB + (v["rego"],))
    row = {"id": pid, "timestamp": now.isoformat(timespec="seconds"), "rego": v["rego"],
           "vehicle": v["desc"], "name": name, "result": "defect" if fails else "ok",
           "fails": "; ".join(fails), "note": note, "photo": photo_file,
           "status": "open" if fails else "", "resolved_by": "", "resolved_at": ""}
    store.append_company_csv(storage.PLANT_REGISTER, storage.PLANT_HEADER, row,
                             subdir=FLEET_SUB + (v["rego"],))
    if fails:
        # Second copy in the one index the foreman screens read. Eight files opened on every page
        # load would make that screen useless; one file does not.
        store.append_company_csv(storage.DEFECTS_REGISTER, storage.PLANT_HEADER, row, subdir=FLEET_SUB)
    return RedirectResponse(f"/plant/{v['rego']}?flash=" + ("defect" if fails else "ok"), status_code=303)


@app.get("/plant/{rego}/photo/{filename}")
def plant_photo(rego: str, filename: str):
    v = fleet.vehicle(rego)
    if not v:
        raise HTTPException(404, "That rego is not on the LFCS fleet list")
    data = store.get_company_file(filename, subdir=FLEET_SUB + (v["rego"],))
    if data is None:
        raise HTTPException(404, "Photo not found")
    return Response(data, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=300"})


@app.post("/j/{job_no}/plant/resolve")
async def plant_resolve(request: Request, job_no: str):
    """Fixed. Clears it off every foreman screen at once, and says who cleared it."""
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    form = await request.form()
    pid = str(form.get("id", "")).strip()
    rego = fleet.norm(str(form.get("rego", "")))
    by = ident.name or "foreman"
    stamp = datetime.now(TZ).isoformat(timespec="seconds")

    def close(rows: list[dict]) -> bool:
        hit = False
        for r in rows:
            if (r.get("id") or "") == pid:
                r.update({"status": "resolved", "resolved_by": by, "resolved_at": stamp})
                hit = True
        return hit

    index = store.read_company_csv(storage.DEFECTS_REGISTER, subdir=FLEET_SUB)
    if not close(index):
        raise HTTPException(404, "No such defect")
    store.write_company_csv(storage.DEFECTS_REGISTER, storage.PLANT_HEADER, index, subdir=FLEET_SUB)
    if rego in fleet.VEHICLES:
        hist = store.read_company_csv(storage.PLANT_REGISTER, subdir=FLEET_SUB + (rego,))
        if close(hist):
            store.write_company_csv(storage.PLANT_REGISTER, storage.PLANT_HEADER, hist,
                                    subdir=FLEET_SUB + (rego,))
    return RedirectResponse(f"/j/{job_no}#defects", status_code=303)


# --------------------------------------------------------------------------- the board
#
# Rocky's idea, and it is the right one: make it look like a game and it gets used. Names drag onto
# work areas, work areas advance a stage, and one drag writes four records at once — the diary line,
# the pricework rate, the T&M evidence and the live muster. Nobody types any of them.
#
# Every drag is an append-only row (app/board.py derives the state back out). Nothing is edited and
# there is no delete button anywhere on the surface, same rule as the register: the moment a board
# can be quietly tidied up it stops being a production record and becomes a whiteboard.


def _board_rows(job_no: str) -> list[dict]:
    return store.read_job_csv(job_no, store.signon_parts, storage.BOARD_REGISTER)


def _board_append(job_no: str, row: dict) -> None:
    store.append_job_csv(job_no, store.signon_parts, storage.BOARD_REGISTER, storage.BOARD_HEADER, row)


def _today_rows(job_no: str, day: str) -> list[dict]:
    return [r for r in _board_rows(job_no) if (r.get("timestamp") or "").startswith(day)]


def _board_crew(job_no: str) -> list[str]:
    """Who can be dragged: this job's crew list first, then anyone else still on the books."""
    out = []
    for n in _pickable(job_no):
        p = crew.person(n)
        if p and (p.get("status") or "active").strip().lower() == "inactive":
            continue
        out.append(n)
    return out


def _live_jobs() -> list[dict]:
    """The jobs with a Sign-On folder. That is the live list and there is no second copy of it."""
    try:
        return store.list_jobs()
    except Exception:
        traceback.print_exc()
        return []


def _roster_rows() -> list[dict]:
    return store.read_company_csv(storage.ROSTER)


def _roster_write(day: date, person: str, job_key: str, by: str, source: str) -> None:
    store.append_company_csv(storage.ROSTER, storage.ROSTER_HEADER, {
        "date": day.isoformat(), "person": person, "job": job_key, "by": by,
        "timestamp": datetime.now(TZ).isoformat(timespec="seconds"), "source": source,
    })


def _open_t5_cards(job_no: str, day: str, rows: list[dict]) -> list[dict]:
    """Take 5s the crew filed today that are not on the board yet.

    A Take 5 already says who and what — the foreman should never have to retype it to put the man
    somewhere. He drags the card onto a zone, or waves it off. Either way it stops nagging.
    """
    handled = set()
    for r in rows:
        got = board.is_t5(r.get("detail", ""))
        if got:
            handled.add(got)
    out = []
    for t in store.list_take5(job_no):
        if not (t.get("start") or "").startswith(day) or t.get("finish"):
            continue
        if (t.get("id") or "") in handled:
            continue
        out.append(t)
    return out


@app.get("/board/{job_no}", response_class=HTMLResponse)
def board_page(request: Request, job_no: str, msg: str = "", d: str = ""):
    """The job as a board. Zones down the screen, the crew in the yard at the top."""
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    now = datetime.now(TZ)
    try:
        when = date.fromisoformat(d.strip())
    except ValueError:
        when = now.date()
    day = when.isoformat()

    rows = _today_rows(job_no, day)
    zones, where = board.state(rows)
    placed = set(where)
    ordered = [z for z in zones.values() if not z.fixed] + [zones[n] for n in board.ALWAYS]
    yard = [n for n in _board_crew(job_no) if n not in placed]
    gone = {}
    for r in rows:
        if (r.get("action") or "") in ("home", "moved"):
            gone[(r.get("person") or "").strip()] = (r.get("action"), r.get("detail") or "")

    return templates.TemplateResponse(request, "board.html", {
        "job_no": job_no, "job_name": job_name, "ident": ident, "msg": msg,
        "when": when, "is_today": when == now.date(), "day": day,
        "prev_d": (when - timedelta(days=1)).isoformat(), "next_d": (when + timedelta(days=1)).isoformat(),
        "zones": ordered, "yard": yard, "gone": gone,
        "deck": board.DECK, "stages": board.STAGES, "estimates": board.ESTIMATES,
        "chip_actions": board.CHIP_ACTIONS,
        "t5_cards": _open_t5_cards(job_no, day, rows),
        "jobs": [j for j in _live_jobs() if j["key"] != job_no],
        "diary_url": "/diary/" + job_no,
    })


@app.post("/board/{job_no}/zone")
async def board_zone(request: Request, job_no: str):
    """Open a zone, push it along a stage, put a time on it, or call it finished."""
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    form = await request.form()
    action = str(form.get("action", "")).strip().lower()
    zone = board.clean_zone(str(form.get("zone", "")))
    detail = str(form.get("detail", "")).strip()
    by = ident.name or "foreman"
    now = datetime.now(TZ)
    day = now.date().isoformat()
    if not zone:
        raise HTTPException(400, "Three words for the work area — 'wall', '5m seats', 'bay 3 deck'")

    if action == "create":
        _board_append(job_no, board.row(now, job_no, zone, "", "created", detail.upper()[:16], by))
    elif action == "stage":
        if detail not in board.STAGES:
            raise HTTPException(400, "Not a stage on the ladder")
        zones, _ = board.state(_today_rows(job_no, day))
        z = zones.get(zone) or board.Zone(zone)
        stop = board.hold_block(z, detail)
        if stop:
            # The hold point. Not a warning he can tap through — the row is simply not written.
            return RedirectResponse("/board/" + job_no + "?msg=" + quote(zone + " " + stop), status_code=303)
        _board_append(job_no, board.row(now, job_no, zone, "", "stage", detail, by))
    elif action == "estimate":
        if detail not in board.ESTIMATES:
            raise HTTPException(400, "1h, 3h, today or tomorrow")
        _board_append(job_no, board.row(now, job_no, zone, "", "estimate", detail, by))
    elif action == "done":
        _board_append(job_no, board.row(now, job_no, zone, "", "done", detail, by))
    else:
        raise HTTPException(400, "create, stage, estimate or done")
    return RedirectResponse("/board/" + job_no, status_code=303)


@app.post("/board/{job_no}/move")
async def board_move(request: Request, job_no: str):
    """A name landed somewhere. One row, and the man comes off wherever he was without being told to."""
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    form = await request.form()
    person = str(form.get("person", "")).strip()
    zone = board.clean_zone(str(form.get("zone", "")))
    action = str(form.get("action", "on")).strip().lower()
    detail = str(form.get("detail", "")).strip()
    t5 = str(form.get("t5", "")).strip()
    if not person:
        raise HTTPException(400, "Which bloke?")
    if action not in ("on", "off", "home", "moved", "dismissed"):
        raise HTTPException(400, "on, off, home, moved or dismissed")
    if action == "on" and not zone:
        raise HTTPException(400, "Drop him on a work area")

    by = ident.name or "foreman"
    now = datetime.now(TZ)
    if t5:
        detail = ("t5:" + t5 + " " + detail).strip()
    if action == "moved" and detail:
        # He is on another job from here on, so tomorrow's roster should say so too.
        try:
            _roster_write(board.next_working_day(now.date()), person, detail, by, "board")
        except Exception:
            traceback.print_exc()
    _board_append(job_no, board.row(now, job_no, zone if action == "on" else "", person, action, detail, by))
    return RedirectResponse("/board/" + job_no, status_code=303)


# --------------------------------------------------------------------------- the admin board
#
# Same component, different list behind it: columns are JOBS and a drag is tomorrow's roster. The
# [POST TO GROUPS] button writes the message somebody already types into WhatsApp every evening —
# that habit is the hook, and if the message ever stops coming off the board the board is dead
# inside a week. It composes the text and hands it over. It never sends anything anywhere.


def _where_today(jobs: list[dict], day: str) -> dict:
    """Last job each bloke actually tapped on today. The board's starting position, not a guess."""
    seen: dict[str, tuple[str, str]] = {}
    for j in jobs:
        try:
            rows = store.list_attendance(j["key"])
        except Exception:
            traceback.print_exc()
            continue
        for r in rows:
            ts = (r.get("timestamp") or "")
            if not ts.startswith(day):
                continue
            n = (r.get("name") or "").strip()
            if n and (n not in seen or ts > seen[n][0]):
                seen[n] = (ts, j["key"])
    return {n: v[1] for n, v in seen.items()}


@app.get("/board", response_class=HTMLResponse)
def admin_board(request: Request):
    """Every live job as a column. Drag a name across and that is where he is in the morning."""
    ident = _who(request)
    if crew.boss_bound_anywhere() and not ident.is_boss:
        return templates.TemplateResponse(request, "foreman_only.html", {
            "job_no": "", "job_name": "The board", "ident": ident}, status_code=403)

    now = datetime.now(TZ)
    jobs = _live_jobs()
    tomorrow = board.next_working_day(now.date())
    rows = _roster_rows()
    set_for = board.roster_by_job(rows, tomorrow.isoformat())
    explicit = {n for names in set_for.values() for n in names}

    # Where a bloke sits before anybody touches the board: tomorrow's row if it exists, else today's
    # row, else wherever he actually tapped on today. Most days it is the same as today and nobody
    # should have to drag twenty men to say so.
    today_roster = board.roster_by_job(rows, now.date().isoformat())
    from_today = {n: k for k, names in today_roster.items() for n in names}
    from_taps = _where_today(jobs, now.date().isoformat())

    people_rows = [p for p in crew.people()
                   if (p.get("status") or "active").strip().lower() != "inactive" and p.get("name")]
    columns = {j["key"]: [] for j in jobs}
    yard = []
    for p in sorted(people_rows, key=lambda r: (r.get("name") or "").lower()):
        n = p["name"].strip()
        where = ""
        for k, names in set_for.items():
            if n in names:
                where = k
        if not where:
            where = from_today.get(n, "") or from_taps.get(n, "")
        chip = {"name": n, "role": (p.get("role") or "worker"), "set": n in explicit}
        if where in columns:
            columns[where].append(chip)
        else:
            yard.append(chip)

    by_job = {k: [c["name"] for c in v] for k, v in columns.items()}
    return templates.TemplateResponse(request, "board_admin.html", {
        "jobs": jobs, "columns": columns, "yard": yard, "tomorrow": tomorrow,
        "message": board.roster_message(tomorrow, jobs, by_job), "ident": ident,
        "explicit": len(explicit),
    })


@app.post("/board/move")
async def admin_move(request: Request):
    """One name, one job, tomorrow. Sunday is skipped — Saturdays are worked and always have been."""
    ident = _who(request)
    if crew.boss_bound_anywhere() and not ident.is_boss:
        raise HTTPException(403, "Foreman only")
    form = await request.form()
    person = str(form.get("person", "")).strip()
    job_key = str(form.get("job", "")).strip()
    if not person:
        raise HTTPException(400, "Which bloke?")
    keys = {j["key"] for j in _live_jobs()}
    if job_key and job_key not in keys:
        raise HTTPException(400, "Not a live job")
    _roster_write(board.next_working_day(datetime.now(TZ).date()), person, job_key,
                  ident.name or "office", "board")
    return RedirectResponse("/board", status_code=303)


# --------------------------------------------------------------------------- /today
#
# The icon on his home screen points at the PERSON, not the job. Phone knows who he is, the roster
# says where he is, and everything opens as that job. Hass drags him to Hornsby on Monday evening;
# Tuesday the same icon opens Hornsby and the bloke never learns there was a decision.


def _job_label(job_key: str) -> str:
    for j in _live_jobs():
        if j["key"] == job_key:
            return j["label"]
    return job_key


def _tomorrow_job(name: str) -> str:
    """'Tomorrow: HORNSBY' on his own screens. Blank if nobody has said, and blank says nothing."""
    if not name:
        return ""
    try:
        key = board.roster_for(_roster_rows(), board.next_working_day(datetime.now(TZ).date()).isoformat(), name)
    except Exception:
        traceback.print_exc()
        return ""
    return _job_label(key) if key else ""


@app.get("/today", response_class=HTMLResponse)
def today_page(request: Request, pick: str = "", fix: str = ""):
    """Rostered — straight through. Not rostered — one screen of big buttons, and his answer is a record."""
    ident = _who(request)
    jobs = _live_jobs()
    now = datetime.now(TZ)
    day = now.date().isoformat()
    here = ""
    if ident.bound and not pick:
        here = board.roster_for(_roster_rows(), day, ident.name)
        if here and any(j["key"] == here for j in jobs):
            return RedirectResponse("/on/" + here + "?via=today", status_code=303)
    return templates.TemplateResponse(request, "today.html", {
        "jobs": jobs, "ident": ident, "fix": fix,
        "name": ident.name if ident.bound else "",
        "tomorrow_job": _tomorrow_job(ident.name) if ident.bound else "",
    })


@app.post("/today")
async def today_pick(request: Request):
    """'Where are you today?' answered. Written down, because the answer is itself a record."""
    ident = _who(request)
    form = await request.form()
    job_key = str(form.get("job", "")).strip()
    fix = str(form.get("fix", "")).strip()
    if not any(j["key"] == job_key for j in _live_jobs()):
        raise HTTPException(400, "Not a live job")
    if ident.bound:
        _roster_write(datetime.now(TZ).date(), ident.name, job_key, ident.name,
                      "corrected" if fix else "self")
    return RedirectResponse("/on/" + job_key + "?via=today", status_code=303)


# --------------------------------------------------------------------------- the live site diary
#
# The same fourteen sections as the Daily Site Report, assembling themselves out of today's
# registers while the day happens. The foreman watches the debrief write itself instead of sitting
# in a ute at five o'clock trying to remember what time the pour finished.
#
# Read-only. Nothing on this page changes anything — the only thing it accepts is his voice on the
# three or four boxes no register can ever know, and those land in their own file.

DIARY_SECTIONS = [
    ("summary", "1. Summary", True),
    ("weather", "2. Weather", True),
    ("labour", "3. Labour on site", False),
    ("plant", "4. Plant on site", False),
    ("works", "5. Work performed", False),
    ("progress", "6. Progress", False),
    ("safety", "7. Safety", False),
    ("materials", "8. Materials", False),
    ("dayworks", "9. Dayworks", False),
    ("photos", "10. Photos taken", False),
    ("issues", "11. Issues", True),
    ("hours", "12. Man hours", False),
    ("notes", "13. Notes", True),
    ("next_day", "14. Next day plan", True),
]
SAYABLE = {k for k, _l, say in DIARY_SECTIONS if say}


def _diary_notes(job_no: str) -> list[dict]:
    return store.read_job_csv(job_no, store.signon_parts, storage.DIARY_NOTES)


def _plant_today(day: str) -> list[dict]:
    """Every vehicle checked today. Eight small files locally; once a day, on one screen."""
    out = []
    for rego in fleet.VEHICLES:
        try:
            rows = store.read_company_csv(storage.PLANT_REGISTER, subdir=FLEET_SUB + (rego,))
        except Exception:
            traceback.print_exc()
            continue
        out += [r for r in rows if (r.get("timestamp") or "").startswith(day)]
    return sorted(out, key=lambda r: r.get("timestamp", ""))


@app.get("/diary/{job_no}", response_class=HTMLResponse)
def diary_page(request: Request, job_no: str, d: str = "", msg: str = "", sec: str = ""):
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    now = datetime.now(TZ)
    try:
        when = date.fromisoformat(d.strip())
    except ValueError:
        when = now.date()
    day = when.isoformat()

    brows = _today_rows(job_no, day)
    zones, _where = board.state(brows)
    day_rows = _job_day(job_no, when, now)
    job_crew = store.get_crew(job_no)
    seen = {n.lower() for n, _ in day_rows}

    notes: dict[str, list[dict]] = {}
    for r in _diary_notes(job_no):
        if (r.get("timestamp") or "").startswith(day):
            notes.setdefault((r.get("section") or "").strip(), []).append(r)

    incidents = [r for r in store.list_incidents(job_no) if (r.get("timestamp") or "").startswith(day)]
    take5s = [r for r in store.list_take5(job_no) if (r.get("start") or "").startswith(day)]
    prestart = [r for r in store.list_register(job_no) if (r.get("timestamp") or "").startswith(day)]
    materials = [r for r in _materials(job_no) if (r.get("timestamp") or "").startswith(day)]
    dockets = [r for r in _tm_rows(job_no) if (r.get("date") or "") == day]
    photos = []
    try:
        photos = sorted(store.list_job_files(job_no, storage.DIARY_PARTS + (day,)))
    except Exception:
        traceback.print_exc()

    return templates.TemplateResponse(request, "diary.html", {
        "job_no": job_no, "job_name": job_name, "ident": ident, "when": when, "day": day,
        "is_today": when == now.date(), "msg": msg, "sec": sec,
        "prev_d": (when - timedelta(days=1)).isoformat(), "next_d": (when + timedelta(days=1)).isoformat(),
        "sections": DIARY_SECTIONS, "notes": notes, "voice_on": voice.available(),
        "works": board.works_lines(brows),
        "zones": [z for z in zones.values() if z.stage or z.estimate or z.done or z.people],
        "day_rows": day_rows, "not_seen": [c for c in job_crew if c.lower() not in seen],
        "prestart": prestart, "take5s": take5s, "incidents": incidents,
        "materials": materials, "labels": storage.MATERIAL_STATUS,
        "dockets": dockets, "photos": photos, "photo_day": day,
        "plant": _plant_today(day), "defects": _open_defects(),
    })


def _say(job_no: str, section: str, text: str, by: str) -> None:
    store.append_job_csv(job_no, store.signon_parts, storage.DIARY_NOTES, storage.DIARY_NOTES_HEADER, {
        "timestamp": datetime.now(TZ).isoformat(timespec="seconds"),
        "section": section, "text": text, "by": by,
    })


@app.post("/diary/{job_no}/voice/{section}")
async def diary_voice(request: Request, job_no: str, section: str):
    """Hold the mic, say it, let go. Deepgram turns it into the line that goes in the report.

    Answers JSON with HTTP 200 either way — a fetch that throws leaves the foreman looking at a
    spinner with no idea what went wrong, which is the exact failure this app exists to stop.
    """
    from fastapi.responses import JSONResponse
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return JSONResponse({"ok": False, "say": "Foreman only."}, status_code=403)
    if section not in SAYABLE:
        raise HTTPException(404, "Nothing to say against that section")
    form = await request.form()
    up = form.get("audio")
    raw = await up.read() if up is not None and hasattr(up, "read") else b""
    ctype = getattr(up, "content_type", "") or "audio/webm"
    try:
        said = voice.transcribe(raw, ctype)
    except voice.VoiceError as e:
        # The words go to him, the detail goes to the log. Never the other way round.
        print("[diary voice] %s job=%s section=%s bytes=%d ctype=%s :: %s"
              % (datetime.now(TZ).isoformat(timespec="seconds"), job_no, section, len(raw), ctype, e.detail))
        return JSONResponse({"ok": False, "say": e.say})
    except Exception:
        traceback.print_exc()
        return JSONResponse({"ok": False, "say": voice.CANT_HEAR})
    _say(job_no, section, said, ident.name or "foreman")
    return JSONResponse({"ok": True, "text": said})


@app.post("/diary/{job_no}/note/{section}")
async def diary_note(request: Request, job_no: str, section: str):
    """Typed, not said. The fallback for a dead mic, a locked-down phone, or a quiet office."""
    job_name = _job_or_404(job_no)
    ident, block = _boss_only(request, job_no, job_name)
    if block:
        return block
    if section not in SAYABLE:
        raise HTTPException(404, "Nothing to say against that section")
    form = await request.form()
    text = str(form.get("text", "")).strip()
    if not text:
        raise HTTPException(400, "Nothing typed")
    _say(job_no, section, text, ident.name or "foreman")
    return RedirectResponse("/diary/" + job_no + "#" + section, status_code=303)
