"""
CA Automator — WSGI application.

Standard library only (plus openpyxl and python-docx for report output), so it
runs under any WSGI host: gunicorn, waitress, mod_wsgi, or the bundled dev server.

    gunicorn -w 4 -b 0.0.0.0:8000 app:application

Routes
    GET  /                      the app shell (offline-capable PWA)
    GET  /sw.js, /manifest...   service worker and manifest (must be same-origin)
    POST /api/auth/login        issue a session token
    GET  /api/bootstrap         everything the browser caches for offline use
    CRUD /api/clients, /api/engagements, /api/workfiles
    POST /api/sync              batched offline autosave beats
    GET  /api/workfiles/<id>/history      autosave snapshots for the restore panel
    POST /api/compute           server-side recomputation
    POST /api/reports           generate xlsx/docx
    GET  /api/reports/<id>/download
    DELETE /api/reports/<id>    remove a generated report — its own generator, or a super admin
    GET/POST/PUT/DELETE /api/rates   rate master maintenance — add/edit/verify/remove rows
    POST /api/rates/rollover    carry a fiscal year's rates forward into a new one, as UNVERIFIED
    POST/DELETE /api/audit/entity-types, /api/audit/criteria   extend the audit module's catalogs
    GET/POST/PUT/DELETE /api/deadlines   compliance calendar / reminders (VAT, audit, account closing, custom)
    GET/PUT /api/notifications, POST /api/notifications/mark-all-read, POST /api/notifications/sweep
        in-app alert bell; sweep also emails if CA_SMTP_* is configured (see notify.py)
    GET  /api/client-directory  dashboard-shaped client register: stage, urgency, assigned staff, reminders, follow-ups
    GET  /api/clients/<id>/linked-workfiles   figures from a client's Fixed Assets/Inventory/Loan/Lease/Tax/TDS/VAT
        workfiles, recomputed fresh, for Financial Statements' "Pull from other modules" panel
    CRUD /api/clients/<id>/followups, /api/followups/<id>   document follow-up log per client
    CRUD /api/clients/<id>/credentials, /api/credentials/<id>   IRD/OCR/custom portal logins (encrypted at rest)
    GET  /api/credentials/<id>/reveal   decrypt one credential (audit-logged)
    POST /api/clients/export    selected clients to Excel
    GET  /api/ca-assistant/status   is ANTHROPIC_API_KEY configured
    GET  /api/ca-assistant/quick-questions, POST /api/ca-assistant/quick
        client-specific status answers straight from this app's own data — no API key needed
    POST /api/ca-assistant/chat     CA reasoning chat, grounded in rate_master + the selected client
    GET  /api/ca-assistant/history  this user's past turns (audit trail, with the knowledge snapshot used)
    GET/POST/PUT/DELETE /api/time-entries   timesheet — logged against an engagement, locked once invoiced
    GET/POST/PUT/DELETE /api/invoices, GET /api/invoices/<id>   billing — draft/sent/paid/void, VAT-aware
    POST /api/invoices/<id>/document   generate the invoice as a Word document
    CRUD /api/documents         document register (upload/list/download/delete)
    POST /api/leads             public — landing page "request access" signup
    GET/PUT /api/leads          firm view of signup requests, status tracking
    GET  /api/calendar/bs-from-ad, /api/calendar/ad-from-bs   date-picker cross-reference (approximate BS)
    GET  /api/import/<key>/template   predefined Excel template for bulk import
    POST /api/import/<key>            parse an uploaded workbook into rows

    Access control: role field is cosmetic; real access is is_super_admin +
    per-user module grants + client ownership/sharing (client.assigned_to,
    client_collaborator). Non-admins never see or edit rate_master (they can
    only leave a remark on a rate); a client is invisible to anyone but its
    owner and its collaborators, super admins excepted.
    GET/POST/PUT /api/admin/users, POST /api/admin/users/<id>/invite
        super-admin only — provisioning, module grants, admin/active toggles
    GET/PUT /api/admin/rate-remarks    super-admin only — triage queue for rate corrections
    GET/POST /api/admin/fiscal-years   super-admin only — add a new fiscal year (BS+AD dates required)
    POST/GET /api/rates/<id>/remarks   any user — flag a rate instead of editing it
    POST/DELETE/GET /api/clients/<id>/collaborators   client owner (or admin) manages sharing
    POST /api/invite/accept, POST /api/auth/forgot, POST /api/auth/reset
        public — token-based account setup and password reset, no session required
    /api/tp/...   Transfer Pricing module (Nepal/India/UAE) — see the "transfer pricing" section below
        for the full route list; gated by _require_module(u, "transfer_pricing") + client visibility
        throughout, rules/countries writable by a super admin only (same lock as Rate Master).
"""
import base64
import csv
import hashlib
import io
import json
import mimetypes
import os
import re
import secrets
import traceback
from datetime import datetime, timedelta, timezone
from urllib.parse import parse_qs

import ai_provider
import bulk_import
import ca_assistant
import country_service
import db
import engines
import fiscal_year_engine
import forensic_engines
import corporate_engines
import business_engines
import ipo_engines
import tax_advisory_engines
import audit_execution_engines
import acct_engines
import markdown_lite
import nepal_calendar
import notify
import oauth_login
import payments
import public_pages
import reports
import resource_engine
import site_chat
import tp_engines
import workflow_engine

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
WEB_DIR = os.path.join(os.path.dirname(BASE_DIR), "web")
REPORT_DIR = os.path.join(db.DATA_DIR, "reports")

ROUTES = []


def route(method, pattern):
    rx = re.compile("^" + pattern + "$")

    def deco(fn):
        ROUTES.append((method, rx, fn))
        return fn
    return deco


class HttpError(Exception):
    def __init__(self, status, message):
        self.status, self.message = status, message


# ------------------------------------------------------------------ plumbing

def _json_body(env):
    try:
        length = int(env.get("CONTENT_LENGTH") or 0)
    except ValueError:
        length = 0
    if not length:
        return {}
    raw = env["wsgi.input"].read(length)
    try:
        return json.loads(raw.decode("utf-8"))
    except Exception:
        raise HttpError(400, "Request body was not valid JSON")


def _firm_subscription_status(firm_id):
    """active (no expiry set, or not yet reached) / grace (expired, within
    GRACE_DAYS) / suspended (past the grace window). Purely a read — never
    touches client/workfile data, only whether staff can sign in and use
    the app (see _user()'s allow_suspended gate)."""
    firm = db.query("SELECT subscription_expires_at FROM firm WHERE id = ?", (firm_id,), one=True)
    expires_at = firm["subscription_expires_at"] if firm else None
    if not expires_at:
        return {"status": "active", "expires_at": None, "grace_ends_at": None}
    today = db.now_iso()[:10]
    grace_ends_at = (datetime.strptime(expires_at, "%Y-%m-%d") +
                     timedelta(days=db.SUBSCRIPTION_GRACE_DAYS)).strftime("%Y-%m-%d")
    if today <= expires_at:
        status = "active"
    elif today <= grace_ends_at:
        status = "grace"
    else:
        status = "suspended"
    return {"status": status, "expires_at": expires_at, "grace_ends_at": grace_ends_at}


def _user(env, allow_suspended=False):
    auth = env.get("HTTP_AUTHORIZATION", "")
    token = auth[7:].strip() if auth.lower().startswith("bearer ") else None
    u = db.user_for_token(token)
    if not u:
        raise HttpError(401, "Sign in again — your session has expired")
    # A CA-Automator platform admin's own access is never gated by a firm's
    # subscription — they're the operator, not a tenant. bootstrap() passes
    # allow_suspended=True so a suspended firm's own staff can still sign in
    # far enough to see *why* (every other route stays blocked).
    if not allow_suspended and not u["is_platform_admin"]:
        sub = _firm_subscription_status(u["firm_id"])
        if sub["status"] == "suspended":
            raise HttpError(402, f"Your firm's CA-Automator subscription expired on {sub['expires_at']} and the "
                                 f"{db.SUBSCRIPTION_GRACE_DAYS}-day grace period has ended. Your data is safe — "
                                 "nothing has been deleted — contact CA-Automator to restore access.")
    return u


def _portal_user(env):
    """The client-portal analogue of _user() — same shape (raises 401 the
    same way), but resolves a client_portal_session token instead of a
    staff auth_session one. The two token spaces never overlap (different
    tables, different secrets.token_urlsafe() draws), so there is no path
    from a portal token to a staff session or vice versa."""
    auth = env.get("HTTP_AUTHORIZATION", "")
    token = auth[7:].strip() if auth.lower().startswith("bearer ") else None
    pu = db.portal_user_for_token(token)
    if not pu:
        raise HttpError(401, "Sign in again — your session has expired")
    return pu


def _device(env):
    return env.get("HTTP_X_DEVICE_ID") or "unknown"


# ------------------------------------------------------------- authorization
# No middleware layer in this app — every route handler calls _user(env)
# itself, so these are plain functions called at the top of a handler, not a
# decorator. A decorator would need to know which resource a route's <id>
# refers to (client directly vs. via engagement vs. via workfile), which
# varies per route — that variability is exactly what these three small,
# explicit helpers sidestep.

def _require_admin(u):
    if not u["is_super_admin"]:
        raise HttpError(403, "Only a super admin can do this")


def _require_platform_admin(u):
    """CA-Automator's own operator role — platform-wide, not firm-scoped.
    Creating a tenant firm, setting its seat limit, and deciding which
    modules its plan includes are all gated here, never by is_super_admin
    (which only means "top user within one firm")."""
    if not u["is_platform_admin"]:
        raise HttpError(403, "Only a CA-Automator platform admin can do this")


def _require_module(u, module_key):
    if u["is_platform_admin"]:
        return
    # The firm's own super admin used to bypass this whole check — meaning a
    # firm subscribed to a single module still had its admin seeing every
    # module in the app. The plan is a real ceiling: even a super admin is
    # capped by what their firm is actually entitled to, they just don't
    # need an individual user_module_grant row on top of that (see below).
    if not db.query("SELECT 1 FROM firm_module_grant WHERE firm_id = ? AND module_key = ?",
                    (u["firm_id"], module_key), one=True):
        raise HttpError(403, "Your firm's plan doesn't include this module — ask CA-Automator to add it")
    if u["is_super_admin"]:
        return
    row = db.query("SELECT expires_at, (expires_at IS NOT NULL AND expires_at < date('now')) AS lapsed"
                   " FROM user_module_grant WHERE user_id = ? AND module_key = ?", (u["id"], module_key), one=True)
    if not row:
        raise HttpError(403, "You don't have access to this module — ask your firm's admin to grant it")
    if row["lapsed"]:
        raise HttpError(403, "Your access to this module expired on " + row["expires_at"] + " — ask your firm's admin to renew it")


def _assert_client_visible(u, client_id):
    # 404, not 403 — a 403 would confirm the client exists to someone who
    # can't see it, the same enumeration problem the forgot-password flow
    # avoids by always returning a generic response.
    if u["is_super_admin"]:
        row = db.query("SELECT 1 FROM client WHERE id = ? AND firm_id = ?", (client_id, u["firm_id"]), one=True)
    else:
        row = db.query(
            "SELECT 1 FROM client WHERE id = ? AND firm_id = ? AND (assigned_to = ? OR id IN"
            " (SELECT client_id FROM client_collaborator WHERE user_id = ?))",
            (client_id, u["firm_id"], u["id"], u["id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")


def _client_id_for_workfile(workfile_id):
    row = db.query(
        "SELECT e.client_id, w.module_key FROM workfile w"
        " JOIN engagement e ON e.id = w.engagement_id WHERE w.id = ?", (workfile_id,), one=True)
    return (row["client_id"], row["module_key"]) if row else (None, None)


# --------------------------------------------------------------------- auth

@route("POST", r"/api/auth/login")
def login(env, m):
    body = _json_body(env)
    email = (body.get("email") or "").lower().strip()
    u = db.query("SELECT * FROM app_user WHERE email = ? AND is_active = 1", (email,), one=True)
    if not u or not db.verify_password(body.get("password") or "", u["pwd_hash"], u["pwd_salt"]):
        raise HttpError(401, "That email and password don't match an account")
    token = db.issue_session(u["id"], body.get("device_id"))
    db.execute("UPDATE app_user SET last_login_at = ? WHERE id = ?", (db.now_iso(), u["id"]))
    db.audit(u["id"], "login", "app_user", u["id"], ip=env.get("REMOTE_ADDR"))
    return {"token": token, "user": {"id": u["id"], "name": u["full_name"], "email": u["email"],
                                     "role": u["role"], "is_super_admin": bool(u["is_super_admin"]),
                                     "is_platform_admin": bool(u["is_platform_admin"])}}


@route("POST", r"/api/auth/logout")
def logout(env, m):
    u = _user(env)
    auth = env.get("HTTP_AUTHORIZATION", "")
    db.execute("UPDATE auth_session SET revoked_at = ? WHERE token = ?",
               (db.now_iso(), auth[7:].strip()))
    return {"ok": True}


# --------------------------------------------------------------- bootstrap

@route("GET", r"/api/bootstrap")
def bootstrap(env, m):
    # allow_suspended=True: a suspended firm's own staff must still be able
    # to load bootstrap far enough to see the paused-service screen (every
    # other route stays blocked by _user()'s normal check).
    u = _user(env, allow_suspended=True)
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True)
    visible = None if u["is_super_admin"] else db.visible_client_ids(u)
    client_filter = "" if visible is None else (
        f" AND cl.id IN ({','.join('?' * len(visible))})" if visible else " AND 0"
    )
    client_args = [] if visible is None else list(visible)
    clients = [_client_out(r) for r in db.query(
        f"SELECT * FROM client cl WHERE firm_id = ?{client_filter} ORDER BY name",
        [u["firm_id"]] + client_args)]
    eng_filter = "" if visible is None else (
        f" AND c.id IN ({','.join('?' * len(visible))})" if visible else " AND 0"
    )
    engs = db.query(
        "SELECT e.*, c.name client_name, f.label_bs fy_label FROM engagement e"
        " JOIN client c ON c.id = e.client_id"
        " LEFT JOIN fiscal_year f ON f.id = e.fiscal_year_id"
        f" WHERE c.firm_id = ?{eng_filter} ORDER BY e.id DESC", [u["firm_id"]] + client_args)
    wfs = db.query(
        "SELECT w.*, s.version, s.updated_at state_updated_at FROM workfile w"
        " LEFT JOIN workfile_state s ON s.workfile_id = w.id"
        " JOIN engagement e ON e.id = w.engagement_id"
        f" JOIN client c ON c.id = e.client_id WHERE c.firm_id = ?{eng_filter} ORDER BY w.id DESC",
        [u["firm_id"]] + client_args)
    horizon = (datetime.now(timezone.utc) + timedelta(days=60)).strftime("%Y-%m-%d")
    deadlines = db.query(
        "SELECT d.*, c.name client_name FROM deadline d JOIN client c ON c.id = d.client_id"
        f" WHERE c.firm_id = ?{eng_filter} AND d.status = 'pending' AND d.due_date_ad <= ?"
        " ORDER BY d.due_date_ad", [u["firm_id"]] + client_args + [horizon])
    entitled_modules = [r["module_key"] for r in db.query(
        "SELECT module_key FROM firm_module_grant WHERE firm_id = ? ORDER BY module_key", (u["firm_id"],))]
    # A platform admin's own access is never capped by any firm's plan (None
    # = unlimited, same sentinel the client already uses). A firm's own
    # super admin used to get that same unlimited pass regardless of what
    # the firm actually subscribed to — now they're capped to the firm's
    # entitled set too, just without needing an individual grant row on top.
    if u["is_platform_admin"]:
        granted_modules = None
    elif u["is_super_admin"]:
        granted_modules = entitled_modules
    else:
        granted_modules = db.granted_modules(u["id"])
    subscription = _firm_subscription_status(u["firm_id"])
    return {
        "user": {"id": u["id"], "name": u["full_name"], "email": u["email"], "role": u["role"],
                "is_super_admin": bool(u["is_super_admin"]), "is_platform_admin": bool(u["is_platform_admin"])},
        "granted_modules": granted_modules,
        "firm": dict(firm, seats_used=db.query(
            "SELECT COUNT(*) n FROM app_user WHERE firm_id = ? AND is_active = 1", (u["firm_id"],), one=True)["n"],
            entitled_modules=entitled_modules,
            subscription_status=subscription["status"],
            subscription_expires_at=subscription["expires_at"],
            subscription_grace_ends_at=subscription["grace_ends_at"],
        ) if firm else firm,
        "clients": clients, "engagements": engs, "workfiles": wfs,
        "fiscal_years": db.query("SELECT * FROM fiscal_year ORDER BY label_bs"),
        # The country-appropriate FY picker list — Nepal's own fiscal_years
        # above for a Nepal firm, financial_period (India Apr-Mar, UAE
        # calendar year) for any other, both normalised to the same shape.
        # See fiscal_year_engine.financial_periods_for_firm's own docstring
        # for why every FY picker and pro-ration lookup in web/app.js and
        # web/engines.js should read THIS field, not fiscal_years above.
        "financial_periods": fiscal_year_engine.financial_periods_for_firm(u["firm_id"]),
        # Both of these used to always resolve Nepal regardless of the
        # requesting firm's own country_code — db.rates()/db.current_fy_label()
        # take no firm/country argument of their own, and nothing here was
        # wrapping the call in country context (unlike the four
        # engines.compute(...) call sites, fixed earlier). A concrete,
        # confirmed consequence: an India-scoped firm's offline cache (this
        # same payload, cached via idbPut('kv', ..., 'rates')) silently held
        # Nepal rates. Fixes both the online Rate Master screen and the
        # offline cache in one place, since they share this payload.
        "current_fy": fiscal_year_engine.current_period_label(u["firm_id"]),
        "rates": db.rates(country_code=country_service.firm_country_code(u["firm_id"])),
        "unverified_rates": db.unverified_count(country_service.firm_country_code(u["firm_id"])),
        "industry_defaults": db.industry_defaults(),
        "fdi_sectors": db.fdi_sectors(),
        "audit_entity_types": db.audit_entity_types(),
        "audit_criteria": db.query("SELECT * FROM audit_criterion ORDER BY sort_order, area"),
        "deadlines": deadlines,
        "appointments": db.query(
            "SELECT b.id, b.name, b.email, b.phone, b.method, b.service, b.status, b.price, s.date, s.start_time, s.end_time"
            " FROM appointment_booking b JOIN appointment_slot s ON s.id = b.slot_id"
            " WHERE b.firm_id = ? AND b.status != 'cancelled' AND s.date >= ? ORDER BY s.date, s.start_time",
            (u["firm_id"], db.now_iso()[:10])),
        "staff": db.query("SELECT id, full_name, role FROM app_user WHERE firm_id = ? AND is_active = 1"
                          " ORDER BY full_name", (u["firm_id"],)),
        "settings": {r["key"]: r["value"] for r in db.query("SELECT * FROM setting")},
        "server_time": db.now_iso(),
        # module_key -> list of country codes it's limited to; a key absent
        # here applies everywhere. The frontend uses this (alongside
        # granted_modules) to decide what a firm's current country should
        # show — see country_service.MODULE_COUNTRY_APPLICABILITY.
        "module_country_applicability": {
            k: sorted(v) for k, v in country_service.MODULE_COUNTRY_APPLICABILITY.items()
        },
    }


# ------------------------------------------------------------------ clients

def _client_out(row):
    d = dict(row)
    try:
        d["service_types"] = json.loads(d["service_types"]) if d.get("service_types") else []
    except (TypeError, ValueError):
        d["service_types"] = []
    return d


@route("GET", r"/api/clients")
def list_clients(env, m):
    u = _user(env)
    if u["is_super_admin"]:
        rows = db.query("SELECT * FROM client WHERE firm_id = ? ORDER BY name", (u["firm_id"],))
    else:
        visible = db.visible_client_ids(u)
        rows = db.query(
            f"SELECT * FROM client WHERE firm_id = ? AND id IN ({','.join('?' * len(visible))}) ORDER BY name",
            [u["firm_id"]] + list(visible)) if visible else []
    return [_client_out(r) for r in rows]


def _next_client_code(firm_id):
    # A plain COUNT(*) collides with an existing code the moment any client
    # has ever been deleted (the count drops but the higher-numbered codes
    # are still in use) — derive the next code from the highest number
    # actually in use instead, so deletions never cause a UNIQUE collision.
    rows = db.query("SELECT code FROM client WHERE firm_id = ?", (firm_id,))
    max_n = 0
    for r in rows:
        found = re.match(r"^CL-(\d+)$", r["code"] or "")
        if found:
            max_n = max(max_n, int(found.group(1)))
    return f"CL-{max_n + 1:04d}"


@route("POST", r"/api/clients")
def create_client(env, m):
    u = _user(env)
    b = _json_body(env)
    if not (b.get("name") or "").strip():
        raise HttpError(400, "A client needs a name")
    code = b.get("code") or _next_client_code(u["firm_id"])
    # Defaults to the firm's own jurisdiction — a reasonable starting
    # suggestion, not a permanent tie: a firm can still have a client
    # incorporated elsewhere, and this field stays independently editable.
    country_code = b.get("country_code") or country_service.firm_country_code(u["firm_id"])
    valid_countries = {c["code"] for c in country_service.active_countries()}
    if country_code not in valid_countries:
        raise HttpError(400, f"Unknown or unsupported country_code {country_code!r} — must be one of {sorted(valid_countries)}")
    cid = db.execute(
        "INSERT INTO client (firm_id, code, name, entity_type, pan, vat_registered, industry,"
        " special_industry, ird_office, address, contact_person, contact_phone, contact_email, notes,"
        " vat_filing_frequency, stage, urgent, service_types, assigned_to, country_code, logo_path)"
        " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (u["firm_id"], code, b["name"].strip(), b.get("entity_type"), b.get("pan"),
         1 if b.get("vat_registered") else 0, b.get("industry"),
         1 if b.get("special_industry") else 0, b.get("ird_office"), b.get("address"),
         b.get("contact_person"), b.get("contact_phone"), b.get("contact_email"), b.get("notes"),
         b.get("vat_filing_frequency"), b.get("stage") or "active", 1 if b.get("urgent") else 0,
         json.dumps(b.get("service_types") or []), b.get("assigned_to") or u["id"],
         country_code, b.get("logo_path")))
    db.audit(u["id"], "create", "client", cid, b["name"])
    return _client_out(db.query("SELECT * FROM client WHERE id = ?", (cid,), one=True))


@route("PUT", r"/api/clients/(\d+)")
def update_client(env, m):
    u = _user(env)
    _assert_client_visible(u, int(m.group(1)))
    b = _json_body(env)
    if "country_code" in b and b["country_code"]:
        valid_countries = {c["code"] for c in country_service.active_countries()}
        if b["country_code"] not in valid_countries:
            raise HttpError(400, f"Unknown or unsupported country_code {b['country_code']!r} — must be one of {sorted(valid_countries)}")
    fields = ["name", "entity_type", "pan", "industry", "ird_office", "address",
              "contact_person", "contact_phone", "contact_email", "status", "notes",
              "vat_filing_frequency", "stage", "assigned_to", "country_code", "logo_path"]
    sets = [f"{f} = ?" for f in fields if f in b] + \
           (["vat_registered = ?"] if "vat_registered" in b else []) + \
           (["special_industry = ?"] if "special_industry" in b else []) + \
           (["urgent = ?"] if "urgent" in b else []) + \
           (["service_types = ?"] if "service_types" in b else [])
    args = [b[f] for f in fields if f in b]
    if "vat_registered" in b:
        args.append(1 if b["vat_registered"] else 0)
    if "special_industry" in b:
        args.append(1 if b["special_industry"] else 0)
    if "urgent" in b:
        args.append(1 if b["urgent"] else 0)
    if "service_types" in b:
        args.append(json.dumps(b["service_types"] or []))
    if not sets:
        raise HttpError(400, "Nothing to update")
    args += [m.group(1), u["firm_id"]]
    db.execute(f"UPDATE client SET {', '.join(sets)} WHERE id = ? AND firm_id = ?", args)
    db.audit(u["id"], "update", "client", m.group(1))
    return _client_out(db.query("SELECT * FROM client WHERE id = ?", (m.group(1),), one=True))


@route("DELETE", r"/api/clients/(\d+)")
def delete_client(env, m):
    u = _user(env)
    _assert_client_visible(u, int(m.group(1)))
    row = db.query("SELECT id, name FROM client WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That client no longer exists")
    db.execute("DELETE FROM client WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]))
    db.audit(u["id"], "delete", "client", int(m.group(1)), row["name"])
    return {"ok": True}


# ---------------------------------------------------------- client sharing
# The owner (client.assigned_to) grants collaboration directly — there is no
# request/approve flow the other way, since the point is that a client stays
# invisible to everyone else until the owner chooses to share it.

def _require_owner_or_admin(u, client_id):
    if u["is_super_admin"]:
        return
    row = db.query("SELECT 1 FROM client WHERE id = ? AND firm_id = ? AND assigned_to = ?",
                    (client_id, u["firm_id"], u["id"]), one=True)
    if not row:
        raise HttpError(403, "Only this client's owner or a super admin can manage sharing")


@route("POST", r"/api/clients/(\d+)/collaborators")
def add_collaborator(env, m):
    u = _user(env)
    client_id = int(m.group(1))
    _require_owner_or_admin(u, client_id)
    b = _json_body(env)
    target = db.query("SELECT id, full_name FROM app_user WHERE id = ? AND firm_id = ? AND is_active = 1",
                       (b.get("user_id"), u["firm_id"]), one=True)
    if not target:
        raise HttpError(400, "Choose a valid, active user to share with")
    try:
        db.execute("INSERT INTO client_collaborator (client_id, user_id, granted_by) VALUES (?,?,?)",
                   (client_id, target["id"], u["id"]))
    except db.IntegrityError:
        pass  # already shared with this user — treat as success, not an error
    db.audit(u["id"], "grant", "client_collaborator", client_id, f"shared with {target['full_name']}")
    return {"ok": True}


@route("DELETE", r"/api/clients/(\d+)/collaborators/(\d+)")
def remove_collaborator(env, m):
    u = _user(env)
    client_id = int(m.group(1))
    _require_owner_or_admin(u, client_id)
    db.execute("DELETE FROM client_collaborator WHERE client_id = ? AND user_id = ?",
               (client_id, int(m.group(2))))
    db.audit(u["id"], "revoke", "client_collaborator", client_id, f"user {m.group(2)}")
    return {"ok": True}


@route("GET", r"/api/clients/(\d+)/collaborators")
def list_collaborators(env, m):
    u = _user(env)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query(
        "SELECT cc.user_id, au.full_name, au.email FROM client_collaborator cc"
        " JOIN app_user au ON au.id = cc.user_id WHERE cc.client_id = ? ORDER BY au.full_name",
        (client_id,))


# -------------------------------------------------------------- engagements

@route("POST", r"/api/engagements")
def create_engagement(env, m):
    u = _user(env)
    b = _json_body(env)
    _assert_client_visible(u, int(b.get("client_id") or 0))
    eid = db.execute(
        "INSERT INTO engagement (client_id, fiscal_year_id, type, title, partner_id, manager_id,"
        " status, start_date, target_date, fee_amount, scope) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
        (b["client_id"], b.get("fiscal_year_id"), b.get("type", "tax"), b.get("title"),
         b.get("partner_id") or u["id"], b.get("manager_id"), b.get("status", "planning"),
         b.get("start_date"), b.get("target_date"), b.get("fee_amount"), b.get("scope", "standalone")))
    db.audit(u["id"], "create", "engagement", eid, b.get("title"))
    workflow_engine.fire_event(u["firm_id"], "engagement.created", {
        "engagement_id": eid, "client_id": b["client_id"], "type": b.get("type", "tax"),
        "title": b.get("title"), "assignee_id": b.get("manager_id") or b.get("partner_id") or u["id"],
    })
    return db.query("SELECT * FROM engagement WHERE id = ?", (eid,), one=True)


@route("PUT", r"/api/engagements/(\d+)")
def update_engagement(env, m):
    u = _user(env)
    eng = db.query("SELECT client_id FROM engagement WHERE id = ?", (m.group(1),), one=True)
    if not eng:
        raise HttpError(404, "Not found")
    _assert_client_visible(u, eng["client_id"])
    b = _json_body(env)
    fields = ["title", "status", "type", "target_date", "fee_amount", "fee_status", "fiscal_year_id", "scope"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if not sets:
        raise HttpError(400, "Nothing to update")
    db.execute(f"UPDATE engagement SET {', '.join(sets)} WHERE id = ?",
               [b[f] for f in fields if f in b] + [m.group(1)])
    return db.query("SELECT * FROM engagement WHERE id = ?", (m.group(1),), one=True)


# ============================================================ ORGANIZATION STRUCTURE
# Branches, departments, teams — org_admin gated, firm-scoped throughout.

@route("GET", r"/api/admin/org/branches")
def list_branches(env, m):
    u = _user(env); _require_module(u, "org_admin")
    return db.query("SELECT * FROM org_branch WHERE firm_id = ? ORDER BY name", (u["firm_id"],))


@route("POST", r"/api/admin/org/branches")
def create_branch(env, m):
    u = _user(env); _require_module(u, "org_admin")
    b = _json_body(env)
    if not (b.get("name") or "").strip():
        raise HttpError(400, "A branch needs a name")
    bid = db.execute("INSERT INTO org_branch (firm_id, name, address, is_head_office) VALUES (?,?,?,?)",
                     (u["firm_id"], b["name"].strip(), b.get("address"), 1 if b.get("is_head_office") else 0))
    db.audit(u["id"], "create", "org_branch", bid, b["name"])
    return db.query("SELECT * FROM org_branch WHERE id = ?", (bid,), one=True)


@route("PUT", r"/api/admin/org/branches/(\d+)")
def update_branch(env, m):
    u = _user(env); _require_module(u, "org_admin")
    row = db.query("SELECT id FROM org_branch WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    b = _json_body(env)
    fields = ["name", "address", "is_head_office"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if not sets:
        raise HttpError(400, "Nothing to update")
    db.execute(f"UPDATE org_branch SET {', '.join(sets)} WHERE id = ?", [b[f] for f in fields if f in b] + [m.group(1)])
    db.audit(u["id"], "update", "org_branch", m.group(1))
    return db.query("SELECT * FROM org_branch WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/org/branches/(\d+)")
def delete_branch(env, m):
    u = _user(env); _require_module(u, "org_admin")
    row = db.query("SELECT id FROM org_branch WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    db.execute("DELETE FROM org_branch WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "org_branch", int(m.group(1)))
    return {"ok": True}


@route("GET", r"/api/admin/org/departments")
def list_departments(env, m):
    u = _user(env); _require_module(u, "org_admin")
    return db.query(
        "SELECT d.*, b.name branch_name FROM org_department d LEFT JOIN org_branch b ON b.id = d.branch_id"
        " WHERE d.firm_id = ? ORDER BY d.name", (u["firm_id"],))


@route("POST", r"/api/admin/org/departments")
def create_department(env, m):
    u = _user(env); _require_module(u, "org_admin")
    b = _json_body(env)
    if not (b.get("name") or "").strip():
        raise HttpError(400, "A department needs a name")
    did = db.execute("INSERT INTO org_department (firm_id, branch_id, name) VALUES (?,?,?)",
                     (u["firm_id"], b.get("branch_id"), b["name"].strip()))
    db.audit(u["id"], "create", "org_department", did, b["name"])
    return db.query("SELECT * FROM org_department WHERE id = ?", (did,), one=True)


@route("PUT", r"/api/admin/org/departments/(\d+)")
def update_department(env, m):
    u = _user(env); _require_module(u, "org_admin")
    row = db.query("SELECT id FROM org_department WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    b = _json_body(env)
    fields = ["name", "branch_id"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if not sets:
        raise HttpError(400, "Nothing to update")
    db.execute(f"UPDATE org_department SET {', '.join(sets)} WHERE id = ?", [b[f] for f in fields if f in b] + [m.group(1)])
    db.audit(u["id"], "update", "org_department", m.group(1))
    return db.query("SELECT * FROM org_department WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/org/departments/(\d+)")
def delete_department(env, m):
    u = _user(env); _require_module(u, "org_admin")
    row = db.query("SELECT id FROM org_department WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    db.execute("DELETE FROM org_department WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "org_department", int(m.group(1)))
    return {"ok": True}


@route("GET", r"/api/admin/org/teams")
def list_teams(env, m):
    u = _user(env); _require_module(u, "org_admin")
    teams = db.query(
        "SELECT t.*, d.name department_name, au.full_name lead_name FROM org_team t"
        " LEFT JOIN org_department d ON d.id = t.department_id LEFT JOIN app_user au ON au.id = t.lead_user_id"
        " WHERE t.firm_id = ? ORDER BY t.name", (u["firm_id"],))
    members = db.query(
        "SELECT tm.team_id, tm.user_id, au.full_name FROM org_team_member tm"
        " JOIN org_team t ON t.id = tm.team_id JOIN app_user au ON au.id = tm.user_id"
        " WHERE t.firm_id = ? ORDER BY au.full_name", (u["firm_id"],))
    by_team = {}
    for mem in members:
        by_team.setdefault(mem["team_id"], []).append({"user_id": mem["user_id"], "full_name": mem["full_name"]})
    for t in teams:
        t["members"] = by_team.get(t["id"], [])
    return teams


@route("POST", r"/api/admin/org/teams")
def create_team(env, m):
    u = _user(env); _require_module(u, "org_admin")
    b = _json_body(env)
    if not (b.get("name") or "").strip():
        raise HttpError(400, "A team needs a name")
    tid = db.execute("INSERT INTO org_team (firm_id, department_id, name, lead_user_id) VALUES (?,?,?,?)",
                     (u["firm_id"], b.get("department_id"), b["name"].strip(), b.get("lead_user_id")))
    db.audit(u["id"], "create", "org_team", tid, b["name"])
    return db.query("SELECT * FROM org_team WHERE id = ?", (tid,), one=True)


@route("PUT", r"/api/admin/org/teams/(\d+)")
def update_team(env, m):
    u = _user(env); _require_module(u, "org_admin")
    row = db.query("SELECT id FROM org_team WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    b = _json_body(env)
    fields = ["name", "department_id", "lead_user_id"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if not sets:
        raise HttpError(400, "Nothing to update")
    db.execute(f"UPDATE org_team SET {', '.join(sets)} WHERE id = ?", [b[f] for f in fields if f in b] + [m.group(1)])
    db.audit(u["id"], "update", "org_team", m.group(1))
    return db.query("SELECT * FROM org_team WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/org/teams/(\d+)")
def delete_team(env, m):
    u = _user(env); _require_module(u, "org_admin")
    row = db.query("SELECT id FROM org_team WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    db.execute("DELETE FROM org_team WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "org_team", int(m.group(1)))
    return {"ok": True}


@route("POST", r"/api/admin/org/teams/(\d+)/members")
def add_team_member(env, m):
    u = _user(env); _require_module(u, "org_admin")
    team = db.query("SELECT id FROM org_team WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not team:
        raise HttpError(404, "Not found")
    b = _json_body(env)
    target = db.query("SELECT id FROM app_user WHERE id = ? AND firm_id = ?", (b.get("user_id"), u["firm_id"]), one=True)
    if not target:
        raise HttpError(400, "Choose a valid user from this firm")
    try:
        db.execute("INSERT INTO org_team_member (team_id, user_id) VALUES (?,?)", (m.group(1), target["id"]))
    except db.IntegrityError:
        pass  # already a member — treat as success
    db.audit(u["id"], "add", "org_team_member", m.group(1), f"user {target['id']}")
    return {"ok": True}


@route("DELETE", r"/api/admin/org/teams/(\d+)/members/(\d+)")
def remove_team_member(env, m):
    u = _user(env); _require_module(u, "org_admin")
    team = db.query("SELECT id FROM org_team WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not team:
        raise HttpError(404, "Not found")
    db.execute("DELETE FROM org_team_member WHERE team_id = ? AND user_id = ?", (m.group(1), m.group(2)))
    db.audit(u["id"], "remove", "org_team_member", m.group(1), f"user {m.group(2)}")
    return {"ok": True}


# ---------------------------------------------------------------- JOB POSITIONS

@route("GET", r"/api/admin/positions")
def list_positions(env, m):
    u = _user(env); _require_module(u, "org_admin")
    return db.query("SELECT * FROM job_position WHERE firm_id = ? ORDER BY hierarchy_level", (u["firm_id"],))


@route("POST", r"/api/admin/positions")
def create_position(env, m):
    u = _user(env); _require_module(u, "org_admin")
    b = _json_body(env)
    if not (b.get("name") or "").strip():
        raise HttpError(400, "A position needs a name")
    try:
        pid = db.execute("INSERT INTO job_position (firm_id, name, hierarchy_level) VALUES (?,?,?)",
                         (u["firm_id"], b["name"].strip(), int(b.get("hierarchy_level") or 0)))
    except db.IntegrityError:
        raise HttpError(400, "This firm already has a position with that name")
    db.audit(u["id"], "create", "job_position", pid, b["name"])
    return db.query("SELECT * FROM job_position WHERE id = ?", (pid,), one=True)


@route("PUT", r"/api/admin/positions/(\d+)")
def update_position(env, m):
    u = _user(env); _require_module(u, "org_admin")
    row = db.query("SELECT id FROM job_position WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    b = _json_body(env)
    fields = ["name", "hierarchy_level"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if not sets:
        raise HttpError(400, "Nothing to update")
    try:
        db.execute(f"UPDATE job_position SET {', '.join(sets)} WHERE id = ?", [b[f] for f in fields if f in b] + [m.group(1)])
    except db.IntegrityError:
        raise HttpError(400, "This firm already has a position with that name")
    db.audit(u["id"], "update", "job_position", m.group(1))
    return db.query("SELECT * FROM job_position WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/positions/(\d+)")
def delete_position(env, m):
    u = _user(env); _require_module(u, "org_admin")
    row = db.query("SELECT id FROM job_position WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    db.execute("DELETE FROM job_position WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "job_position", int(m.group(1)))
    return {"ok": True}


# ------------------------------------------------------------- EMPLOYEE PROFILES
# 1:1 with app_user (see schema.sql's employee_profile comment). GET lists every
# active staff member for the firm with their profile fields left-joined, so a
# user who has never been given a profile row yet still shows up (with nulls) —
# the admin screen always has something to render a form against.

@route("GET", r"/api/admin/employees")
def list_employees(env, m):
    u = _user(env); _require_module(u, "org_admin")
    return db.query(
        "SELECT au.id user_id, au.full_name, au.email, au.role, au.is_active,"
        " ep.id employee_profile_id, ep.employee_code, ep.job_position_id, jp.name job_position_name,"
        " ep.department_id, od.name department_name, ep.branch_id, ob.name branch_name,"
        " ep.reports_to_user_id, ru.full_name reports_to_name, ep.date_of_joining, ep.employment_type,"
        " ep.weekly_capacity_hours, ep.cost_rate, ep.billing_rate, ep.phone, ep.notes"
        " FROM app_user au"
        " LEFT JOIN employee_profile ep ON ep.user_id = au.id"
        " LEFT JOIN job_position jp ON jp.id = ep.job_position_id"
        " LEFT JOIN org_department od ON od.id = ep.department_id"
        " LEFT JOIN org_branch ob ON ob.id = ep.branch_id"
        " LEFT JOIN app_user ru ON ru.id = ep.reports_to_user_id"
        " WHERE au.firm_id = ? ORDER BY au.full_name", (u["firm_id"],))


@route("PUT", r"/api/admin/employees/(\d+)")
def upsert_employee_profile(env, m):
    """One route for both create and update — employee_profile is 1:1 with
    app_user, so there's never a meaningful distinction between "set up this
    employee's profile for the first time" and "edit it" from the caller's
    point of view. Whichever it is, this ends with exactly one row."""
    u = _user(env); _require_module(u, "org_admin")
    target_user_id = int(m.group(1))
    target = db.query("SELECT id FROM app_user WHERE id = ? AND firm_id = ?", (target_user_id, u["firm_id"]), one=True)
    if not target:
        raise HttpError(404, "Not found")
    b = _json_body(env)
    if b.get("reports_to_user_id") and int(b["reports_to_user_id"]) == target_user_id:
        raise HttpError(400, "An employee can't report to themselves")
    fields = ["employee_code", "job_position_id", "department_id", "branch_id", "reports_to_user_id",
              "date_of_joining", "employment_type", "weekly_capacity_hours", "cost_rate", "billing_rate",
              "phone", "notes"]
    existing = db.query("SELECT id FROM employee_profile WHERE user_id = ?", (target_user_id,), one=True)
    if existing:
        sets = [f"{f} = ?" for f in fields if f in b]
        if sets:
            db.execute(f"UPDATE employee_profile SET {', '.join(sets)} WHERE user_id = ?",
                      [b[f] for f in fields if f in b] + [target_user_id])
    else:
        cols = [f for f in fields if f in b]
        placeholders = ",".join("?" * (len(cols) + 1))
        db.execute(f"INSERT INTO employee_profile (user_id, {', '.join(cols)}) VALUES ({placeholders})",
                  [target_user_id] + [b[f] for f in cols])
    db.audit(u["id"], "update", "employee_profile", target_user_id)
    return db.query(
        "SELECT ep.*, au.full_name FROM employee_profile ep JOIN app_user au ON au.id = ep.user_id"
        " WHERE ep.user_id = ?", (target_user_id,), one=True)


# --------------------------------------------------------------- CLIENT CONTACTS
# Same shape/precedent as client_followup/client_credential above — visible to
# anyone who can see the client, not gated behind a module.

@route("GET", r"/api/clients/(\d+)/contacts")
def list_client_contacts(env, m):
    u = _user(env)
    _assert_client_visible(u, int(m.group(1)))
    return db.query("SELECT * FROM client_contact WHERE client_id = ? ORDER BY is_primary DESC, name", (m.group(1),))


@route("POST", r"/api/clients/(\d+)/contacts")
def create_client_contact(env, m):
    u = _user(env)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("name") or "").strip():
        raise HttpError(400, "A contact needs a name")
    cid = db.execute(
        "INSERT INTO client_contact (client_id, name, designation, email, phone, is_primary, notes) VALUES (?,?,?,?,?,?,?)",
        (client_id, b["name"].strip(), b.get("designation"), b.get("email"), b.get("phone"),
         1 if b.get("is_primary") else 0, b.get("notes")))
    db.audit(u["id"], "create", "client_contact", cid, b["name"])
    return db.query("SELECT * FROM client_contact WHERE id = ?", (cid,), one=True)


@route("PUT", r"/api/contacts/(\d+)")
def update_client_contact(env, m):
    u = _user(env)
    row = db.query("SELECT client_id FROM client_contact WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "Not found")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["name", "designation", "email", "phone", "is_primary", "notes"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if not sets:
        raise HttpError(400, "Nothing to update")
    db.execute(f"UPDATE client_contact SET {', '.join(sets)} WHERE id = ?", [b[f] for f in fields if f in b] + [m.group(1)])
    db.audit(u["id"], "update", "client_contact", m.group(1))
    return db.query("SELECT * FROM client_contact WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/contacts/(\d+)")
def delete_client_contact(env, m):
    u = _user(env)
    row = db.query("SELECT client_id FROM client_contact WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "Not found")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM client_contact WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "client_contact", int(m.group(1)))
    return {"ok": True}


# ------------------------------------------------------------------------- TASKS
# No existing routes at all before this — the task table was pure schema with
# nothing built on top. client_id is denormalized onto task (also present via
# engagement) so a task can optionally stand alone against a client with no
# engagement, matching the column's own nullable engagement_id design.

def _task_out(row):
    d = dict(row)
    d["checklist"] = db.query(
        "SELECT id, label, is_done, sort_order FROM task_checklist_item WHERE task_id = ? ORDER BY sort_order, id",
        (row["id"],))
    d["depends_on"] = [r["depends_on_task_id"] for r in db.query(
        "SELECT depends_on_task_id FROM task_dependency WHERE task_id = ?", (row["id"],))]
    d["actual_hours"] = (db.query("SELECT SUM(hours) h FROM time_entry WHERE task_id = ?", (row["id"],), one=True) or {}).get("h") or 0
    return d


@route("GET", r"/api/tasks")
def list_tasks(env, m):
    u = _user(env)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    sql = ("SELECT t.*, au.full_name assignee_name, ru.full_name reviewer_name,"
           " c.name client_name, e.title engagement_title FROM task t"
           " LEFT JOIN app_user au ON au.id = t.assignee_id"
           " LEFT JOIN app_user ru ON ru.id = t.reviewer_id"
           " LEFT JOIN client c ON c.id = t.client_id"
           " LEFT JOIN engagement e ON e.id = t.engagement_id"
           " WHERE (t.client_id IN (SELECT id FROM client WHERE firm_id = ?)"
           "        OR t.engagement_id IN (SELECT e2.id FROM engagement e2 JOIN client c2 ON c2.id = e2.client_id WHERE c2.firm_id = ?)"
           # A standalone task (no client, no engagement — e.g. a personal
           # task with neither, which the plain Tasks-page "New task" form
           # creates) has no other column to scope it to a firm by. Without
           # this it would insert successfully (create_task never requires
           # either) and then never appear in any list at all — caught by
           # actually creating one through the real UI, not by reading the
           # code. assignee_id is guaranteed set (create_task defaults it to
           # the creator) so this always has something to match against.
           "        OR t.assignee_id IN (SELECT id FROM app_user WHERE firm_id = ?))")
    args = [u["firm_id"], u["firm_id"], u["firm_id"]]
    if qs.get("assignee_id"):
        sql += " AND t.assignee_id = ?"; args.append(qs["assignee_id"][0])
    if qs.get("status"):
        sql += " AND t.status = ?"; args.append(qs["status"][0])
    if qs.get("client_id"):
        sql += " AND t.client_id = ?"; args.append(qs["client_id"][0])
    if qs.get("engagement_id"):
        sql += " AND t.engagement_id = ?"; args.append(qs["engagement_id"][0])
    if not qs.get("include_subtasks"):
        sql += " AND t.parent_task_id IS NULL"
    sql += " ORDER BY t.due_date IS NULL, t.due_date, t.id DESC"
    return [_task_out(r) for r in db.query(sql, args)]


@route("POST", r"/api/tasks")
def create_task(env, m):
    u = _user(env)
    b = _json_body(env)
    if not (b.get("title") or "").strip():
        raise HttpError(400, "A task needs a title")
    if b.get("client_id"):
        _assert_client_visible(u, int(b["client_id"]))
    tid = db.execute(
        "INSERT INTO task (engagement_id, client_id, title, detail, assignee_id, reviewer_id, priority,"
        " due_date, status, parent_task_id, estimated_hours) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
        (b.get("engagement_id"), b.get("client_id"), b["title"].strip(), b.get("detail"),
         # Defaults to the creator, same convention as create_engagement's
         # partner_id — a task always ends up with at least one column
         # (assignee_id) tying it to a firm, since client_id/engagement_id
         # are both optional (a standalone task is allowed).
         b.get("assignee_id") or u["id"],
         b.get("reviewer_id"), b.get("priority", "normal"), b.get("due_date"), b.get("status", "open"),
         b.get("parent_task_id"), b.get("estimated_hours")))
    db.audit(u["id"], "create", "task", tid, b["title"])
    return _task_out(db.query("SELECT * FROM task WHERE id = ?", (tid,), one=True))


@route("PUT", r"/api/tasks/(\d+)")
def update_task(env, m):
    u = _user(env)
    task = db.query("SELECT * FROM task WHERE id = ?", (m.group(1),), one=True)
    if not task:
        raise HttpError(404, "Not found")
    b = _json_body(env)
    fields = ["title", "detail", "assignee_id", "reviewer_id", "priority", "due_date", "status",
              "parent_task_id", "estimated_hours", "engagement_id", "client_id"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if not sets:
        raise HttpError(400, "Nothing to update")
    was_open = task["status"] != "done"
    db.execute(f"UPDATE task SET {', '.join(sets)}"
              + (", completed_at = datetime('now')" if b.get("status") == "done" and was_open else "")
              + " WHERE id = ?", [b[f] for f in fields if f in b] + [m.group(1)])
    db.audit(u["id"], "update", "task", m.group(1))
    if b.get("status") == "done" and was_open:
        workflow_engine.fire_event(u["firm_id"], "task.completed", {
            "task_id": int(m.group(1)), "title": task["title"], "assignee_id": task["assignee_id"],
        })
    return _task_out(db.query("SELECT * FROM task WHERE id = ?", (m.group(1),), one=True))


@route("DELETE", r"/api/tasks/(\d+)")
def delete_task(env, m):
    u = _user(env)
    task = db.query("SELECT id FROM task WHERE id = ?", (m.group(1),), one=True)
    if not task:
        raise HttpError(404, "Not found")
    db.execute("DELETE FROM task WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "task", int(m.group(1)))
    return {"ok": True}


@route("POST", r"/api/tasks/(\d+)/checklist")
def add_checklist_item(env, m):
    u = _user(env)
    task = db.query("SELECT id FROM task WHERE id = ?", (m.group(1),), one=True)
    if not task:
        raise HttpError(404, "Not found")
    b = _json_body(env)
    if not (b.get("label") or "").strip():
        raise HttpError(400, "A checklist item needs a label")
    n = db.query("SELECT COUNT(*) n FROM task_checklist_item WHERE task_id = ?", (m.group(1),), one=True)["n"]
    cid = db.execute("INSERT INTO task_checklist_item (task_id, label, sort_order) VALUES (?,?,?)",
                     (m.group(1), b["label"].strip(), n))
    return db.query("SELECT * FROM task_checklist_item WHERE id = ?", (cid,), one=True)


@route("PUT", r"/api/checklist-items/(\d+)")
def update_checklist_item(env, m):
    _user(env)
    row = db.query("SELECT id FROM task_checklist_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "Not found")
    b = _json_body(env)
    fields = ["label", "is_done", "sort_order"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if not sets:
        raise HttpError(400, "Nothing to update")
    db.execute(f"UPDATE task_checklist_item SET {', '.join(sets)} WHERE id = ?", [b[f] for f in fields if f in b] + [m.group(1)])
    return db.query("SELECT * FROM task_checklist_item WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/checklist-items/(\d+)")
def delete_checklist_item(env, m):
    _user(env)
    db.execute("DELETE FROM task_checklist_item WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("POST", r"/api/tasks/(\d+)/dependencies")
def add_task_dependency(env, m):
    u = _user(env)
    task_id = int(m.group(1))
    b = _json_body(env)
    depends_on = int(b.get("depends_on_task_id") or 0)
    if not depends_on or depends_on == task_id:
        raise HttpError(400, "Choose a different, valid task to depend on")
    if not db.query("SELECT id FROM task WHERE id = ?", (depends_on,), one=True):
        raise HttpError(404, "That task doesn't exist")
    try:
        db.execute("INSERT INTO task_dependency (task_id, depends_on_task_id) VALUES (?,?)", (task_id, depends_on))
    except db.IntegrityError:
        pass  # already recorded
    db.audit(u["id"], "create", "task_dependency", task_id, f"depends on {depends_on}")
    return {"ok": True}


@route("DELETE", r"/api/tasks/(\d+)/dependencies/(\d+)")
def remove_task_dependency(env, m):
    u = _user(env)
    db.execute("DELETE FROM task_dependency WHERE task_id = ? AND depends_on_task_id = ?", (m.group(1), m.group(2)))
    db.audit(u["id"], "delete", "task_dependency", m.group(1), f"depends on {m.group(2)}")
    return {"ok": True}


# --------------------------------------------------------- ENGAGEMENT TEAM ROSTER

@route("GET", r"/api/engagements/(\d+)/members")
def list_engagement_members(env, m):
    u = _user(env)
    eng = db.query("SELECT client_id FROM engagement WHERE id = ?", (m.group(1),), one=True)
    if not eng:
        raise HttpError(404, "Not found")
    _assert_client_visible(u, eng["client_id"])
    return db.query(
        "SELECT em.*, au.full_name FROM engagement_member em JOIN app_user au ON au.id = em.user_id"
        " WHERE em.engagement_id = ? ORDER BY au.full_name", (m.group(1),))


@route("POST", r"/api/engagements/(\d+)/members")
def add_engagement_member(env, m):
    u = _user(env)
    eng = db.query("SELECT client_id FROM engagement WHERE id = ?", (m.group(1),), one=True)
    if not eng:
        raise HttpError(404, "Not found")
    _assert_client_visible(u, eng["client_id"])
    b = _json_body(env)
    target = db.query("SELECT id FROM app_user WHERE id = ? AND firm_id = ?", (b.get("user_id"), u["firm_id"]), one=True)
    if not target:
        raise HttpError(400, "Choose a valid user from this firm")
    try:
        eid = db.execute(
            "INSERT INTO engagement_member (engagement_id, user_id, role_on_engagement, allocated_hours) VALUES (?,?,?,?)",
            (m.group(1), target["id"], b.get("role_on_engagement", "associate"), b.get("allocated_hours")))
    except db.IntegrityError:
        raise HttpError(400, "This person is already on the engagement team")
    db.audit(u["id"], "add", "engagement_member", m.group(1), f"user {target['id']}")
    return db.query("SELECT * FROM engagement_member WHERE id = ?", (eid,), one=True)


@route("DELETE", r"/api/engagement-members/(\d+)")
def remove_engagement_member(env, m):
    u = _user(env)
    row = db.query(
        "SELECT em.engagement_id, e.client_id FROM engagement_member em"
        " JOIN engagement e ON e.id = em.engagement_id WHERE em.id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "Not found")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM engagement_member WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "remove", "engagement_member", row["engagement_id"])
    return {"ok": True}


# ---------------------------------------------------------------------- CALENDAR
# Internal team calendar — distinct from the public appointment_slot/booking.

@route("GET", r"/api/calendar/events")
def list_calendar_events(env, m):
    u = _user(env)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    sql = "SELECT ce.*, au.full_name owner_name FROM calendar_event ce LEFT JOIN app_user au ON au.id = ce.user_id WHERE ce.firm_id = ?"
    args = [u["firm_id"]]
    if not u["is_super_admin"]:
        sql += " AND (ce.user_id IS NULL OR ce.user_id = ?)"; args.append(u["id"])
    if qs.get("from"):
        sql += " AND ce.start_at >= ?"; args.append(qs["from"][0])
    if qs.get("to"):
        sql += " AND ce.start_at <= ?"; args.append(qs["to"][0])
    sql += " ORDER BY ce.start_at"
    return db.query(sql, args)


@route("POST", r"/api/calendar/events")
def create_calendar_event(env, m):
    u = _user(env)
    b = _json_body(env)
    if not (b.get("title") or "").strip():
        raise HttpError(400, "An event needs a title")
    if not b.get("start_at"):
        raise HttpError(400, "An event needs a start date/time")
    eid = db.execute(
        "INSERT INTO calendar_event (firm_id, user_id, title, description, start_at, end_at,"
        " related_entity_type, related_entity_id, created_by) VALUES (?,?,?,?,?,?,?,?,?)",
        (u["firm_id"], b.get("user_id"), b["title"].strip(), b.get("description"), b["start_at"], b.get("end_at"),
         b.get("related_entity_type"), b.get("related_entity_id"), u["id"]))
    db.audit(u["id"], "create", "calendar_event", eid, b["title"])
    return db.query("SELECT * FROM calendar_event WHERE id = ?", (eid,), one=True)


@route("PUT", r"/api/calendar/events/(\d+)")
def update_calendar_event(env, m):
    u = _user(env)
    row = db.query("SELECT id, created_by, user_id FROM calendar_event WHERE id = ? AND firm_id = ?",
                   (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    if not u["is_super_admin"] and row["created_by"] != u["id"] and row["user_id"] != u["id"]:
        raise HttpError(403, "Only this event's owner or a super admin can edit it")
    b = _json_body(env)
    fields = ["title", "description", "start_at", "end_at", "user_id"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if not sets:
        raise HttpError(400, "Nothing to update")
    db.execute(f"UPDATE calendar_event SET {', '.join(sets)} WHERE id = ?", [b[f] for f in fields if f in b] + [m.group(1)])
    db.audit(u["id"], "update", "calendar_event", m.group(1))
    return db.query("SELECT * FROM calendar_event WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/calendar/events/(\d+)")
def delete_calendar_event(env, m):
    u = _user(env)
    row = db.query("SELECT id, created_by, user_id FROM calendar_event WHERE id = ? AND firm_id = ?",
                   (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    if not u["is_super_admin"] and row["created_by"] != u["id"] and row["user_id"] != u["id"]:
        raise HttpError(403, "Only this event's owner or a super admin can delete it")
    db.execute("DELETE FROM calendar_event WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "calendar_event", int(m.group(1)))
    return {"ok": True}


# ------------------------------------------------------------------ WORKFLOW RULES
# EVENT -> CONDITION -> ACTION, firm-configurable. See workflow_engine.py for the
# fixed set of trigger_event names the app actually fires and what each
# action_type does — this is CRUD only, no execution logic here.

@route("GET", r"/api/admin/workflow-rules")
def list_workflow_rules(env, m):
    u = _user(env); _require_module(u, "org_admin")
    return db.query("SELECT * FROM workflow_rule WHERE firm_id = ? ORDER BY trigger_event, name", (u["firm_id"],))


@route("POST", r"/api/admin/workflow-rules")
def create_workflow_rule(env, m):
    u = _user(env); _require_module(u, "org_admin")
    b = _json_body(env)
    if not (b.get("name") or "").strip():
        raise HttpError(400, "A rule needs a name")
    if b.get("trigger_event") not in workflow_engine.TRIGGER_EVENTS:
        raise HttpError(400, f"trigger_event must be one of {sorted(workflow_engine.TRIGGER_EVENTS)}")
    if b.get("action_type") not in workflow_engine.ACTION_TYPES:
        raise HttpError(400, f"action_type must be one of {sorted(workflow_engine.ACTION_TYPES)}")
    rid = db.execute(
        "INSERT INTO workflow_rule (firm_id, name, trigger_event, condition_json, action_type, action_json,"
        " is_active, created_by) VALUES (?,?,?,?,?,?,?,?)",
        (u["firm_id"], b["name"].strip(), b["trigger_event"],
         json.dumps(b["condition_json"]) if b.get("condition_json") else None,
         b["action_type"], json.dumps(b.get("action_json") or {}), 1 if b.get("is_active", True) else 0, u["id"]))
    db.audit(u["id"], "create", "workflow_rule", rid, b["name"])
    return db.query("SELECT * FROM workflow_rule WHERE id = ?", (rid,), one=True)


@route("PUT", r"/api/admin/workflow-rules/(\d+)")
def update_workflow_rule(env, m):
    u = _user(env); _require_module(u, "org_admin")
    row = db.query("SELECT id FROM workflow_rule WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    b = _json_body(env)
    if "trigger_event" in b and b["trigger_event"] not in workflow_engine.TRIGGER_EVENTS:
        raise HttpError(400, f"trigger_event must be one of {sorted(workflow_engine.TRIGGER_EVENTS)}")
    if "action_type" in b and b["action_type"] not in workflow_engine.ACTION_TYPES:
        raise HttpError(400, f"action_type must be one of {sorted(workflow_engine.ACTION_TYPES)}")
    fields = ["name", "trigger_event", "action_type", "is_active"]
    sets = [f"{f} = ?" for f in fields if f in b]
    args = [b[f] for f in fields if f in b]
    if "condition_json" in b:
        sets.append("condition_json = ?"); args.append(json.dumps(b["condition_json"]) if b["condition_json"] else None)
    if "action_json" in b:
        sets.append("action_json = ?"); args.append(json.dumps(b["action_json"] or {}))
    if not sets:
        raise HttpError(400, "Nothing to update")
    db.execute(f"UPDATE workflow_rule SET {', '.join(sets)} WHERE id = ?", args + [m.group(1)])
    db.audit(u["id"], "update", "workflow_rule", m.group(1))
    return db.query("SELECT * FROM workflow_rule WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/workflow-rules/(\d+)")
def delete_workflow_rule(env, m):
    u = _user(env); _require_module(u, "org_admin")
    row = db.query("SELECT id FROM workflow_rule WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    db.execute("DELETE FROM workflow_rule WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "workflow_rule", int(m.group(1)))
    return {"ok": True}


# ------------------------------------------------------------------------- SKILLS

@route("GET", r"/api/admin/skills")
def list_skills(env, m):
    u = _user(env); _require_module(u, "org_admin")
    return db.query("SELECT * FROM skill WHERE firm_id = ? ORDER BY name", (u["firm_id"],))


@route("POST", r"/api/admin/skills")
def create_skill(env, m):
    u = _user(env); _require_module(u, "org_admin")
    b = _json_body(env)
    if not (b.get("name") or "").strip():
        raise HttpError(400, "A skill needs a name")
    try:
        sid = db.execute("INSERT INTO skill (firm_id, name) VALUES (?,?)", (u["firm_id"], b["name"].strip()))
    except db.IntegrityError:
        raise HttpError(400, "This firm already has a skill with that name")
    db.audit(u["id"], "create", "skill", sid, b["name"])
    return db.query("SELECT * FROM skill WHERE id = ?", (sid,), one=True)


@route("DELETE", r"/api/admin/skills/(\d+)")
def delete_skill(env, m):
    u = _user(env); _require_module(u, "org_admin")
    row = db.query("SELECT id FROM skill WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "Not found")
    db.execute("DELETE FROM skill WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "skill", int(m.group(1)))
    return {"ok": True}


@route("GET", r"/api/employees/(\d+)/skills")
def list_employee_skills(env, m):
    u = _user(env)
    target = db.query("SELECT id FROM app_user WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not target:
        raise HttpError(404, "Not found")
    return db.query(
        "SELECT es.id, es.skill_id, s.name skill_name, es.proficiency FROM employee_skill es"
        " JOIN skill s ON s.id = es.skill_id WHERE es.user_id = ? ORDER BY s.name", (m.group(1),))


@route("PUT", r"/api/employees/(\d+)/skills")
def set_employee_skills(env, m):
    """Replaces the employee's whole skill set with exactly what's posted —
    simpler for the admin form (a multi-select + proficiency per row) than
    tracking individual add/remove calls, and this list is short enough
    that a full replace is cheap."""
    u = _user(env); _require_module(u, "org_admin")
    target_user_id = int(m.group(1))
    target = db.query("SELECT id FROM app_user WHERE id = ? AND firm_id = ?", (target_user_id, u["firm_id"]), one=True)
    if not target:
        raise HttpError(404, "Not found")
    b = _json_body(env)
    skills = b.get("skills") or []  # [{skill_id, proficiency}]
    db.execute("DELETE FROM employee_skill WHERE user_id = ?", (target_user_id,))
    for s in skills:
        sk = db.query("SELECT id FROM skill WHERE id = ? AND firm_id = ?", (s.get("skill_id"), u["firm_id"]), one=True)
        if not sk:
            continue
        db.execute("INSERT INTO employee_skill (user_id, skill_id, proficiency) VALUES (?,?,?)",
                  (target_user_id, sk["id"], int(s.get("proficiency") or 3)))
    db.audit(u["id"], "update", "employee_skill", target_user_id)
    return list_employee_skills(env, m)


# -------------------------------------------------------------- RESOURCE ENGINE
# See resource_engine.py for the actual computation. The manager retains full
# control — this only ever returns a ranked suggestion, never assigns anything.

@route("GET", r"/api/resource/workload")
def resource_workload(env, m):
    u = _user(env); _require_module(u, "org_admin")
    return resource_engine.workload_report(u["firm_id"])


@route("GET", r"/api/resource/suggest")
def resource_suggest(env, m):
    u = _user(env); _require_module(u, "org_admin")
    qs = parse_qs(env.get("QUERY_STRING", ""))
    job_position_id = int(qs["job_position_id"][0]) if qs.get("job_position_id") else None
    skill_id = int(qs["skill_id"][0]) if qs.get("skill_id") else None
    return resource_engine.suggest_candidates(u["firm_id"], job_position_id=job_position_id, skill_id=skill_id)


# ------------------------------------------------------------- time & billing
# time_entry.invoice_id is the single source of truth for "billed" — NULL
# means still available to pull onto a new invoice; once it's on any invoice
# (even a draft) it's locked from further editing so two invoices can never
# double-bill the same hours. Deleting a draft invoice frees the entries
# again (sets invoice_id back to NULL) rather than deleting the time itself.

@route("GET", r"/api/time-entries")
def list_time_entries(env, m):
    u = _user(env)
    _require_module(u, "billing")
    qs = parse_qs(env.get("QUERY_STRING", ""))
    sql = ("SELECT t.*, au.full_name user_name, e.title engagement_title, e.client_id,"
           " c.name client_name FROM time_entry t"
           " JOIN engagement e ON e.id = t.engagement_id JOIN client c ON c.id = e.client_id"
           " LEFT JOIN app_user au ON au.id = t.user_id WHERE c.firm_id = ?")
    args = [u["firm_id"]]
    if qs.get("engagement_id"):
        sql += " AND t.engagement_id = ?"; args.append(qs["engagement_id"][0])
    if qs.get("client_id"):
        sql += " AND e.client_id = ?"; args.append(qs["client_id"][0])
    if (qs.get("unbilled") or [""])[0] == "1":
        sql += " AND t.invoice_id IS NULL"
    sql += " ORDER BY t.work_date DESC, t.id DESC"
    return db.query(sql, args)


@route("POST", r"/api/time-entries")
def create_time_entry(env, m):
    u = _user(env)
    _require_module(u, "billing")
    b = _json_body(env)
    eng = db.query("SELECT e.id FROM engagement e JOIN client c ON c.id = e.client_id"
                   " WHERE e.id = ? AND c.firm_id = ?", (b.get("engagement_id"), u["firm_id"]), one=True)
    if not eng:
        raise HttpError(400, "Choose a valid engagement")
    if not b.get("work_date") or not b.get("hours"):
        raise HttpError(400, "A time entry needs a date and hours")
    tid = db.execute(
        "INSERT INTO time_entry (engagement_id, user_id, work_date, hours, narrative, billable, rate)"
        " VALUES (?,?,?,?,?,?,?)",
        (b["engagement_id"], b.get("user_id") or u["id"], b["work_date"], float(b["hours"]),
         b.get("narrative"), 1 if b.get("billable", True) else 0, b.get("rate")))
    return db.query("SELECT * FROM time_entry WHERE id = ?", (tid,), one=True)


@route("PUT", r"/api/time-entries/(\d+)")
def update_time_entry(env, m):
    u = _user(env)
    _require_module(u, "billing")
    b = _json_body(env)
    row = db.query("SELECT t.* FROM time_entry t JOIN engagement e ON e.id = t.engagement_id"
                    " JOIN client c ON c.id = e.client_id WHERE t.id = ? AND c.firm_id = ?",
                    (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That time entry no longer exists")
    if row["invoice_id"]:
        raise HttpError(400, "This entry is already on an invoice — void the invoice to free it up first")
    fields = ["work_date", "hours", "narrative", "rate"]
    sets = [f"{f} = ?" for f in fields if f in b] + (["billable = ?"] if "billable" in b else [])
    args = [b[f] for f in fields if f in b] + ([1 if b["billable"] else 0] if "billable" in b else [])
    if not sets:
        raise HttpError(400, "Nothing to update")
    args.append(m.group(1))
    db.execute(f"UPDATE time_entry SET {', '.join(sets)} WHERE id = ?", args)
    return db.query("SELECT * FROM time_entry WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/time-entries/(\d+)")
def delete_time_entry(env, m):
    u = _user(env)
    _require_module(u, "billing")
    row = db.query("SELECT t.id, t.invoice_id FROM time_entry t JOIN engagement e ON e.id = t.engagement_id"
                    " JOIN client c ON c.id = e.client_id WHERE t.id = ? AND c.firm_id = ?",
                    (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That time entry no longer exists")
    if row["invoice_id"]:
        raise HttpError(400, "This entry is already on an invoice — void the invoice to free it up first")
    db.execute("DELETE FROM time_entry WHERE id = ?", (m.group(1),))
    return {"ok": True}


def _invoice_out(u, invoice_id):
    inv = db.query("SELECT i.*, c.name client_name FROM invoice i JOIN client c ON c.id = i.client_id"
                    " WHERE i.id = ? AND i.firm_id = ?", (invoice_id, u["firm_id"]), one=True)
    if not inv:
        return None
    inv = dict(inv)
    inv["lines"] = db.query("SELECT il.*, e.title engagement_title FROM invoice_line il"
                            " LEFT JOIN engagement e ON e.id = il.engagement_id"
                            " WHERE il.invoice_id = ? ORDER BY il.id", (invoice_id,))
    return inv


@route("GET", r"/api/invoices")
def list_invoices(env, m):
    u = _user(env)
    _require_module(u, "billing")
    return db.query("SELECT i.*, c.name client_name FROM invoice i JOIN client c ON c.id = i.client_id"
                    " WHERE i.firm_id = ? ORDER BY i.invoice_date DESC, i.id DESC", (u["firm_id"],))


@route("GET", r"/api/invoices/(\d+)")
def get_invoice(env, m):
    u = _user(env)
    _require_module(u, "billing")
    inv = _invoice_out(u, int(m.group(1)))
    if not inv:
        raise HttpError(404, "That invoice no longer exists")
    return inv


@route("POST", r"/api/invoices")
def create_invoice(env, m):
    u = _user(env)
    _require_module(u, "billing")
    b = _json_body(env)
    client = db.query("SELECT id FROM client WHERE id = ? AND firm_id = ?", (b.get("client_id"), u["firm_id"]), one=True)
    if not client:
        raise HttpError(400, "Choose a valid client")
    manual_lines = b.get("lines") or []
    time_entry_ids = b.get("time_entry_ids") or []
    if not manual_lines and not time_entry_ids:
        raise HttpError(400, "An invoice needs at least one line — a manual line or some billable time")

    time_rows = []
    if time_entry_ids:
        placeholders = ",".join("?" * len(time_entry_ids))
        time_rows = db.query(
            f"SELECT t.*, e.title engagement_title FROM time_entry t JOIN engagement e ON e.id = t.engagement_id"
            f" JOIN client c ON c.id = e.client_id WHERE t.id IN ({placeholders}) AND c.firm_id = ?"
            f" AND t.invoice_id IS NULL AND e.client_id = ?",
            time_entry_ids + [u["firm_id"], b["client_id"]])
        if len(time_rows) != len(set(time_entry_ids)):
            raise HttpError(400, "Some of those time entries are no longer available — refresh and try again")

    lines = []
    for l in manual_lines:
        amount = float(l.get("amount") or (float(l.get("hours") or 0) * float(l.get("rate") or 0)))
        lines.append({"engagement_id": l.get("engagement_id"), "description": l.get("description") or "Professional fees",
                      "hours": l.get("hours"), "rate": l.get("rate"), "amount": round(amount, 2)})
    for t in time_rows:
        rate = t["rate"] or 0
        lines.append({"engagement_id": t["engagement_id"],
                      "description": (t["narrative"] or t["engagement_title"] or "Professional time")
                                     + f" ({t['work_date']})",
                      "hours": t["hours"], "rate": rate, "amount": round(t["hours"] * rate, 2)})

    subtotal = round(sum(l["amount"] for l in lines), 2)
    apply_vat = bool(b.get("apply_vat"))
    vat_rate = 0.0
    if apply_vat:
        rates, _ = db.rate_dict("vat", b.get("fy") or db.current_fy_label())
        rate_row = rates.get("standard_rate")
        vat_rate = (rate_row["value_num"] if rate_row and rate_row["value_num"] is not None else 0.13)
    vat_amount = round(subtotal * vat_rate, 2)
    total = round(subtotal + vat_amount, 2)

    invoice_no = b.get("invoice_no") or f"INV-{(db.query('SELECT COUNT(*) n FROM invoice WHERE firm_id = ?', (u['firm_id'],), one=True)['n'] + 1):04d}"
    iid = db.execute(
        "INSERT INTO invoice (firm_id, client_id, invoice_no, invoice_date, due_date, status, subtotal,"
        " vat_rate, vat_amount, total, notes, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
        (u["firm_id"], b["client_id"], invoice_no, b.get("invoice_date") or db.now_iso()[:10],
         b.get("due_date"), "draft", subtotal, vat_rate, vat_amount, total, b.get("notes"), u["id"]))
    for l in lines:
        db.execute(
            "INSERT INTO invoice_line (invoice_id, engagement_id, description, hours, rate, amount)"
            " VALUES (?,?,?,?,?,?)",
            (iid, l["engagement_id"], l["description"], l["hours"], l["rate"], l["amount"]))
    if time_entry_ids:
        placeholders = ",".join("?" * len(time_entry_ids))
        db.execute(f"UPDATE time_entry SET invoice_id = ? WHERE id IN ({placeholders})", [iid] + time_entry_ids)
    db.audit(u["id"], "create", "invoice", iid, invoice_no)
    return _invoice_out(u, iid)


@route("PUT", r"/api/invoices/(\d+)")
def update_invoice(env, m):
    u = _user(env)
    _require_module(u, "billing")
    b = _json_body(env)
    row = db.query("SELECT * FROM invoice WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That invoice no longer exists")
    fields = ["status", "due_date", "notes", "paid_amount", "paid_date"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if not sets:
        raise HttpError(400, "Nothing to update")
    args = [b[f] for f in fields if f in b]
    if b.get("status") == "paid" and "paid_amount" not in b:
        sets.append("paid_amount = ?"); args.append(row["total"])
    if b.get("status") == "paid" and "paid_date" not in b:
        sets.append("paid_date = ?"); args.append(db.now_iso()[:10])
    args.append(m.group(1))
    db.execute(f"UPDATE invoice SET {', '.join(sets)} WHERE id = ?", args)
    db.audit(u["id"], "update", "invoice", m.group(1), b.get("status"))
    return _invoice_out(u, int(m.group(1)))


@route("DELETE", r"/api/invoices/(\d+)")
def delete_invoice(env, m):
    u = _user(env)
    _require_module(u, "billing")
    row = db.query("SELECT * FROM invoice WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That invoice no longer exists")
    if row["status"] != "draft":
        raise HttpError(400, "Only a draft invoice can be deleted — void it instead if it's already been sent")
    db.execute("UPDATE time_entry SET invoice_id = NULL WHERE invoice_id = ?", (m.group(1),))
    db.execute("DELETE FROM invoice WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "invoice", int(m.group(1)), row["invoice_no"])
    return {"ok": True}


@route("POST", r"/api/invoices/(\d+)/document")
def invoice_document(env, m):
    u = _user(env)
    _require_module(u, "billing")
    inv = _invoice_out(u, int(m.group(1)))
    if not inv:
        raise HttpError(404, "That invoice no longer exists")
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": inv["client_name"], "fy": "", "unverified": []}
    try:
        path, filename = reports.build("invoice", "docx", inv, meta, REPORT_DIR)
    except Exception:
        raise HttpError(500, "The invoice document could not be built: " + traceback.format_exc()[-1000:])
    rid = db.execute(
        "INSERT INTO report (module_key, format, filename, storage_path, params, status, generated_by, byte_size)"
        " VALUES (?,?,?,?,?,?,?,?)",
        ("invoice", "docx", filename, path, json.dumps({"invoice_id": inv["id"]}), "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "invoice", inv["id"], inv["invoice_no"])
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download",
            "byte_size": os.path.getsize(path)}


# ---------------------------------------------------------------- workfiles

@route("POST", r"/api/workfiles")
def create_workfile(env, m):
    u = _user(env)
    b = _json_body(env)
    eng = db.query("SELECT client_id FROM engagement WHERE id = ?", (b.get("engagement_id"),), one=True)
    if not eng:
        raise HttpError(400, "Choose a valid engagement")
    _assert_client_visible(u, eng["client_id"])
    _require_module(u, b["module_key"])
    wid = db.execute(
        "INSERT INTO workfile (engagement_id, module_key, title, prepared_by) VALUES (?,?,?,?)",
        (b["engagement_id"], b["module_key"], b.get("title") or b["module_key"], u["id"]))
    db.save_state(wid, b.get("payload") or {}, u["id"], _device(env), origin="manual")
    db.audit(u["id"], "create", "workfile", wid, b.get("title"))
    wf = db.query("SELECT * FROM workfile WHERE id = ?", (wid,), one=True)
    wf["payload"] = b.get("payload") or {}
    wf["version"] = 1
    return wf


@route("GET", r"/api/workfiles/(\d+)")
def get_workfile(env, m):
    # Read access follows client visibility only, not module grant — revoking
    # a module blocks new work, it never erases the ability to see past work.
    u = _user(env)
    client_id, _ = _client_id_for_workfile(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That workfile no longer exists")
    _assert_client_visible(u, client_id)
    wf = db.query("SELECT * FROM workfile WHERE id = ?", (m.group(1),), one=True)
    st = db.get_state(wf["id"])
    wf["payload"] = json.loads(st["payload"]) if st else {}
    wf["version"] = st["version"] if st else 0
    return wf


@route("PUT", r"/api/workfiles/(\d+)/state")
def put_state(env, m):
    u = _user(env)
    client_id, module_key = _client_id_for_workfile(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That workfile no longer exists")
    _assert_client_visible(u, client_id)
    _require_module(u, module_key)
    b = _json_body(env)
    res = db.save_state(int(m.group(1)), b.get("payload") or {}, u["id"], _device(env),
                        origin=b.get("origin", "auto"), base_version=b.get("base_version"),
                        client_seq=b.get("client_seq"))
    return res


@route("GET", r"/api/workfiles/(\d+)/history")
def history(env, m):
    u = _user(env)
    client_id, _ = _client_id_for_workfile(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That workfile no longer exists")
    _assert_client_visible(u, client_id)
    rows = db.query(
        "SELECT id, origin, device_id, saved_at, byte_size FROM autosave_snapshot"
        " WHERE workfile_id = ? ORDER BY saved_at DESC LIMIT 200", (m.group(1),))
    return {"snapshots": rows}


@route("GET", r"/api/snapshots/(\d+)")
def snapshot(env, m):
    u = _user(env)
    row = db.query("SELECT * FROM autosave_snapshot WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That snapshot has been pruned")
    client_id, _ = _client_id_for_workfile(row["workfile_id"])
    if client_id is not None:
        _assert_client_visible(u, client_id)
    return {"payload": json.loads(row["payload"]), "saved_at": row["saved_at"], "origin": row["origin"]}


# --------------------------------------------------------------------- sync
# The browser posts every queued offline beat here in one batch on reconnect.

@route("POST", r"/api/sync")
def sync(env, m):
    # The offline-queue drain path — beat() fires every 5 minutes with nobody
    # watching, so this is the sharpest edge in the whole access-control
    # model: a locally-queued change for a client/module the user has since
    # lost access to must be rejected here, not silently accepted, or
    # everything gated elsewhere is trivially bypassed by going offline first.
    u = _user(env)
    b = _json_body(env)
    device = _device(env)
    results = []
    for beat in sorted(b.get("beats", []), key=lambda x: (x.get("workfile_id"), x.get("client_seq") or 0)):
        wid = beat.get("workfile_id")
        if not wid:
            continue
        try:
            client_id, module_key = _client_id_for_workfile(int(wid))
            if client_id is None:
                raise HttpError(404, "That workfile no longer exists")
            _assert_client_visible(u, client_id)
            _require_module(u, module_key)
            r = db.save_state(int(wid), beat.get("payload") or {}, u["id"], device,
                              origin="sync", base_version=beat.get("base_version"),
                              client_seq=beat.get("client_seq"))
            r["workfile_id"] = wid
            r["client_seq"] = beat.get("client_seq")
            results.append(r)
        except HttpError as e:
            results.append({"workfile_id": wid, "error": e.message})
        except Exception as e:
            results.append({"workfile_id": wid, "error": str(e)})
    db.audit(u["id"], "sync", "workfile", None, f"{len(results)} beats from {device}")
    return {"results": results, "server_time": db.now_iso()}


@route("GET", r"/api/conflicts")
def conflicts(env, m):
    u = _user(env)
    rows = db.query("SELECT * FROM sync_conflict WHERE resolved_at IS NULL ORDER BY detected_at DESC")
    visible = None if u["is_super_admin"] else db.visible_client_ids(u)
    out = []
    for r in rows:
        client_id, _ = _client_id_for_workfile(r["workfile_id"])
        if client_id is None:
            continue
        if visible is not None and client_id not in visible:
            continue
        r["incoming"] = json.loads(r["incoming"])
        r["server_state"] = json.loads(r["server_state"])
        out.append(r)
    return {"conflicts": out}


@route("POST", r"/api/conflicts/(\d+)/resolve")
def resolve_conflict(env, m):
    u = _user(env)
    b = _json_body(env)
    row = db.query("SELECT * FROM sync_conflict WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That conflict has already been resolved")
    client_id, _ = _client_id_for_workfile(row["workfile_id"])
    if client_id is not None:
        _assert_client_visible(u, client_id)
    choice = b.get("resolution", "keep_server")
    if choice == "keep_incoming":
        db.save_state(row["workfile_id"], json.loads(row["incoming"]), u["id"], _device(env), origin="manual")
    elif choice == "merged" and b.get("payload") is not None:
        db.save_state(row["workfile_id"], b["payload"], u["id"], _device(env), origin="manual")
    db.execute("UPDATE sync_conflict SET resolved_at = ?, resolution = ? WHERE id = ?",
               (db.now_iso(), choice, m.group(1)))
    return {"ok": True}


# ------------------------------------------------------------------ compute

@route("POST", r"/api/compute")
def compute(env, m):
    u = _user(env)
    b = _json_body(env)
    _require_module(u, b.get("module_key"))
    # client_id (optional) resolves this specific client's own jurisdiction
    # if set, not just the firm's — a firm's clients aren't all necessarily
    # in the firm's own home country. workfile_id is the more common way a
    # caller actually knows which client this is for.
    client_id = b.get("client_id")
    if not client_id and b.get("workfile_id"):
        client_id, _ = _client_id_for_workfile(b["workfile_id"])
    try:
        with country_service.use_country_for_client(u["firm_id"], client_id):
            result = engines.compute(b["module_key"], b.get("fy") or db.current_fy_label(), b.get("data") or {})
    except ValueError as e:
        raise HttpError(400, str(e))
    return {"result": result}


# ------------------------------------------------------------------ reports

@route("POST", r"/api/reports")
def make_report(env, m):
    u = _user(env)
    b = _json_body(env)
    module_key = b.get("module_key")
    _require_module(u, module_key)
    fmt = b.get("format", "xlsx")
    fy = b.get("fy") or db.current_fy_label()
    data = b.get("data") or {}
    wid = b.get("workfile_id")

    if not data and wid:
        st = db.get_state(int(wid))
        data = json.loads(st["payload"]) if st else {}

    client_id = b.get("client_id")
    if not client_id and wid:
        client_id, _ = _client_id_for_workfile(int(wid))
    if b.get("result"):
        result = b["result"]
    else:
        with country_service.use_country_for_client(u["firm_id"], client_id):
            result = engines.compute(module_key, fy, data)
    unverified = result.get("unverified") or []
    if module_key == "salary_tds":
        unverified = sorted({c for e in result.get("employees", []) for c in e.get("unverified", [])})

    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": b.get("firm_name") or firm.get("name", ""),
            "firm_address": firm.get("address", ""),
            "client": b.get("client_name", ""), "fy": fy,
            "title": b.get("title") or reports.TITLES.get(module_key, module_key),
            "unverified": unverified}

    try:
        path, filename = reports.build(module_key, fmt, result, meta, REPORT_DIR)
    except ValueError as e:
        raise HttpError(400, str(e))
    except Exception:
        rid = db.execute(
            "INSERT INTO report (workfile_id, engagement_id, module_key, format, filename, params,"
            " status, error_text, generated_by) VALUES (?,?,?,?,?,?,?,?,?)",
            (wid, b.get("engagement_id"), module_key, fmt, "", json.dumps(data)[:20000],
             "failed", traceback.format_exc()[-2000:], u["id"]))
        raise HttpError(500, "The report could not be built. It has been logged as report #%d." % rid)

    rid = db.execute(
        "INSERT INTO report (workfile_id, engagement_id, module_key, format, filename, storage_path,"
        " params, status, generated_by, byte_size) VALUES (?,?,?,?,?,?,?,?,?,?)",
        (wid, b.get("engagement_id"), module_key, fmt, filename, path,
         json.dumps(data)[:20000], "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "report", rid, f"{module_key}.{fmt}")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download",
            "byte_size": os.path.getsize(path)}


@route("GET", r"/api/reports")
def list_reports(env, m):
    u = _user(env)
    # generated_by is set on every report this app has ever created — joining
    # through it to app_user.firm_id scopes the list to the caller's own firm.
    # (Previously unscoped: any authenticated user of any firm could list, and
    # by extension guess-download or delete, every other firm's reports.)
    return db.query(
        "SELECT r.id, r.module_key, r.format, r.filename, r.status, r.generated_at, r.byte_size, r.generated_by"
        " FROM report r JOIN app_user au ON au.id = r.generated_by"
        " WHERE au.firm_id = ? ORDER BY r.generated_at DESC LIMIT 200", (u["firm_id"],))


def _report_visible_or_404(u, report_id):
    r = db.query("SELECT r.* FROM report r JOIN app_user au ON au.id = r.generated_by WHERE r.id = ? AND au.firm_id = ?",
                 (report_id, u["firm_id"]), one=True)
    if not r:
        raise HttpError(404, "That report no longer exists")
    return r


@route("GET", r"/api/reports/(\d+)/download")
def download_report(env, m):
    u = _user(env)
    r = _report_visible_or_404(u, m.group(1))
    if not r["storage_path"] or not os.path.exists(r["storage_path"]):
        raise HttpError(404, "That file is no longer on the server. Generate it again.")
    with open(r["storage_path"], "rb") as f:
        blob = f.read()
    ctype = ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
             if r["format"] == "xlsx" else
             "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
    return ("__raw__", blob, ctype, r["filename"])


def _delete_report_row(u, r):
    if not u["is_super_admin"] and r["generated_by"] != u["id"]:
        raise HttpError(403, "Only the person who generated this report, or a super admin, can delete it")
    if r["storage_path"] and os.path.exists(r["storage_path"]):
        os.remove(r["storage_path"])
    db.execute("DELETE FROM report WHERE id = ?", (r["id"],))
    db.audit(u["id"], "delete", "report", r["id"], r["filename"])


@route("DELETE", r"/api/reports/(\d+)")
def delete_report(env, m):
    u = _user(env)
    r = _report_visible_or_404(u, m.group(1))
    _delete_report_row(u, r)
    return {"ok": True}


@route("POST", r"/api/reports/bulk-delete")
def bulk_delete_reports(env, m):
    u = _user(env)
    b = _json_body(env)
    ids = [int(x) for x in (b.get("ids") or [])]
    deleted, skipped = [], []
    for rid in ids:
        r = db.query("SELECT r.* FROM report r JOIN app_user au ON au.id = r.generated_by WHERE r.id = ? AND au.firm_id = ?",
                     (rid, u["firm_id"]), one=True)
        if not r or (not u["is_super_admin"] and r["generated_by"] != u["id"]):
            skipped.append(rid)
            continue
        _delete_report_row(u, r)
        deleted.append(rid)
    return {"deleted": deleted, "skipped": skipped}


# -------------------------------------------------------------- rate master

@route("GET", r"/api/rates")
def get_rates(env, m):
    u = _user(env)
    country_code = country_service.firm_country_code(u["firm_id"])
    return {"rates": db.rates(country_code=country_code), "unverified": db.unverified_count(country_code)}


@route("POST", r"/api/rates")
def create_rate(env, m):
    # Lets a firm add a new slab, a new company-rate category, a new pool — any
    # row the engines discover dynamically (see engines._dynamic_slabs and
    # _dynamic_categories) — from the Rate Master screen, not just edit values
    # already seeded. New rows always start UNVERIFIED regardless of what's posted.
    # rate_master is shared platform-wide statutory data used by every tenant
    # firm's computations, not any one firm's own record — so writes are
    # platform-admin-only, never a firm's own is_super_admin (see also
    # update_rate/delete_rate/rollover_rates below). A firm can still flag a
    # rate it thinks is wrong via POST /api/rates/:id/remarks.
    u = _user(env)
    _require_platform_admin(u)
    b = _json_body(env)
    domain, code, label = (b.get("domain") or "").strip(), (b.get("code") or "").strip(), (b.get("label") or "").strip()
    if not domain or not code or not label:
        raise HttpError(400, "Domain, code and label are required")
    try:
        rid = db.execute(
            "INSERT INTO rate_master (domain, code, label, value_num, value_text, unit, applies_to,"
            " fiscal_year_label, source, clause, country_code, verification_status, notes)"
            " VALUES (?,?,?,?,?,?,?,?,?,?,?,'UNVERIFIED',?)",
            (domain, code, label, b.get("value_num"), b.get("value_text"), b.get("unit"),
             b.get("applies_to") or "all", b.get("fiscal_year_label") or db.current_fy_label(),
             b.get("source") or "", b.get("clause") or "", b.get("country_code") or "NP", b.get("notes") or ""))
    except db.IntegrityError:
        raise HttpError(400, "A rate with this domain, code, fiscal year and applies-to already exists")
    db.audit(u["id"], "create", "rate_master", rid, f"{domain}.{code}")
    return db.query("SELECT * FROM rate_master WHERE id = ?", (rid,), one=True)


@route("DELETE", r"/api/rates/(\d+)")
def delete_rate(env, m):
    u = _user(env)
    _require_platform_admin(u)
    row = db.query("SELECT domain, code FROM rate_master WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That rate no longer exists")
    db.execute("DELETE FROM rate_master WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "rate_master", int(m.group(1)), f"{row['domain']}.{row['code']}")
    return {"ok": True}


@route("POST", r"/api/rates/rollover")
def rollover_rates(env, m):
    # Copies a fiscal year's rate_master rows forward into a new one, all as
    # fresh UNVERIFIED placeholders — the yearly step that makes a new FY
    # (like the current one, once the fiscal_year table itself has a row for
    # it) actually usable without every computation coming back empty.
    u = _user(env)
    _require_platform_admin(u)
    b = _json_body(env)
    from_fy, to_fy = b.get("from_fy"), b.get("to_fy")
    if not from_fy or not to_fy:
        raise HttpError(400, "from_fy and to_fy are required")
    n = db.rollover_rates(from_fy, to_fy)
    db.audit(u["id"], "rollover", "rate_master", None, f"{from_fy} -> {to_fy}: {n} rows")
    return {"inserted": n}


# ------------------------------------------------------------- audit catalogs
# The same "always add via the screen, never a rebuild" discipline as Rate
# Master, for the two catalogs behind the audit module: entity types and the
# checklist bank. "Other" (entity type) is protected from deletion since the
# module's own logic depends on it always being selectable.

@route("POST", r"/api/audit/entity-types")
def create_audit_entity_type(env, m):
    u = _user(env)
    b = _json_body(env)
    name = (b.get("name") or "").strip()
    if not name:
        raise HttpError(400, "An entity type needs a name")
    try:
        eid = db.execute("INSERT INTO audit_entity_type (name, note, is_builtin, sort_order) VALUES (?,?,0,?)",
                         (name, b.get("note") or "", b.get("sort_order") or 500))
    except db.IntegrityError:
        raise HttpError(400, "An entity type with this name already exists")
    db.audit(u["id"], "create", "audit_entity_type", eid, name)
    return db.query("SELECT * FROM audit_entity_type WHERE id = ?", (eid,), one=True)


@route("DELETE", r"/api/audit/entity-types/(\d+)")
def delete_audit_entity_type(env, m):
    u = _user(env)
    row = db.query("SELECT * FROM audit_entity_type WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That entity type no longer exists")
    if row["name"] == "Other":
        raise HttpError(400, "\"Other\" can't be removed — the module always needs a fallback option")
    db.execute("DELETE FROM audit_entity_type WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "audit_entity_type", int(m.group(1)), row["name"])
    return {"ok": True}


@route("POST", r"/api/audit/criteria")
def create_audit_criterion(env, m):
    u = _user(env)
    b = _json_body(env)
    area, criterion = (b.get("area") or "").strip(), (b.get("criterion") or "").strip()
    if not area or not criterion:
        raise HttpError(400, "An area and the criterion text are required")
    applies_to = b.get("applies_to")
    applies_to_val = "all" if not applies_to or applies_to == "all" else json.dumps(
        applies_to if isinstance(applies_to, list) else [applies_to])
    cid = db.execute(
        "INSERT INTO audit_criterion (area, criterion, applies_to, sort_order, is_builtin) VALUES (?,?,?,?,0)",
        (area, criterion, applies_to_val, b.get("sort_order") or 500))
    db.audit(u["id"], "create", "audit_criterion", cid, f"{area}: {criterion[:60]}")
    return db.query("SELECT * FROM audit_criterion WHERE id = ?", (cid,), one=True)


@route("DELETE", r"/api/audit/criteria/(\d+)")
def delete_audit_criterion(env, m):
    u = _user(env)
    row = db.query("SELECT * FROM audit_criterion WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That criterion no longer exists")
    db.execute("DELETE FROM audit_criterion WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "audit_criterion", int(m.group(1)), row["area"])
    return {"ok": True}


@route("PUT", r"/api/rates/(\d+)")
def update_rate(env, m):
    u = _user(env)
    _require_platform_admin(u)
    b = _json_body(env)
    db.execute(
        "UPDATE rate_master SET value_num = COALESCE(?, value_num), value_text = COALESCE(?, value_text),"
        " source = COALESCE(?, source), clause = COALESCE(?, clause), notes = COALESCE(?, notes),"
        " verification_status = COALESCE(?, verification_status), last_verified_on = ?, verified_by = ?"
        " WHERE id = ?",
        (b.get("value_num"), b.get("value_text"), b.get("source"), b.get("clause"), b.get("notes"),
         b.get("verification_status"), b.get("last_verified_on") or db.now_iso()[:10], u["id"], m.group(1)))
    db.audit(u["id"], "update", "rate_master", m.group(1), b.get("verification_status"))
    return db.query("SELECT * FROM rate_master WHERE id = ?", (m.group(1),), one=True)


# ----------------------------------------------------------- rate remarks
# Rate Master itself is edit-locked to CA-Automator's platform admin (see
# _require_platform_admin above) — it's shared statutory data, not any one
# firm's own record. Anyone (including a firm's own super admin) can flag a
# rate they think is wrong here instead — a one-shot remark, not a
# discussion thread; the resolver's note shows against the submitter's own
# remark. "Admin" below (the resolve endpoint) also means platform admin.

@route("POST", r"/api/rates/(\d+)/remarks")
def create_rate_remark(env, m):
    u = _user(env)
    b = _json_body(env)
    remark = (b.get("remark") or "").strip()
    if not remark:
        raise HttpError(400, "The remark can't be empty")
    row = db.query("SELECT id FROM rate_master WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That rate no longer exists")
    rid = db.execute("INSERT INTO rate_remark (rate_id, user_id, remark) VALUES (?,?,?)",
                     (m.group(1), u["id"], remark))
    db.audit(u["id"], "create", "rate_remark", rid, remark[:80])
    return db.query("SELECT * FROM rate_remark WHERE id = ?", (rid,), one=True)


@route("GET", r"/api/rates/(\d+)/remarks")
def list_rate_remarks(env, m):
    _user(env)
    return db.query(
        "SELECT rr.*, au.full_name submitted_by_name, ru.full_name resolved_by_name FROM rate_remark rr"
        " JOIN app_user au ON au.id = rr.user_id"
        " LEFT JOIN app_user ru ON ru.id = rr.resolved_by"
        " WHERE rr.rate_id = ? ORDER BY rr.created_at DESC", (m.group(1),))


# ---------------------------------------------------------------- deadlines

@route("GET", r"/api/deadlines")
def get_deadlines(env, m):
    u = _user(env)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    days = int((qs.get("days") or ["60"])[0])
    horizon = (datetime.now(timezone.utc) + timedelta(days=days)).strftime("%Y-%m-%d")
    return db.query(
        "SELECT d.*, c.name client_name FROM deadline d JOIN client c ON c.id = d.client_id"
        " WHERE c.firm_id = ? AND d.due_date_ad <= ? ORDER BY d.due_date_ad",
        (u["firm_id"], horizon))


@route("POST", r"/api/deadlines")
def create_deadline(env, m):
    u = _user(env)
    b = _json_body(env)
    _assert_client_visible(u, int(b.get("client_id") or 0))
    did = db.execute(
        "INSERT INTO deadline (client_id, obligation_code, title, period_label, due_date_bs,"
        " due_date_ad, assigned_to) VALUES (?,?,?,?,?,?,?)",
        (b["client_id"], b.get("obligation_code"), b["title"], b.get("period_label"),
         b.get("due_date_bs"), b["due_date_ad"], b.get("assigned_to") or u["id"]))
    return db.query("SELECT * FROM deadline WHERE id = ?", (did,), one=True)


@route("PUT", r"/api/deadlines/(\d+)")
def update_deadline(env, m):
    u = _user(env)
    row = db.query("SELECT client_id FROM deadline WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That reminder no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    db.execute("UPDATE deadline SET status = ?, filed_on = ?, reference_no = ? WHERE id = ?",
               (b.get("status", "filed"), b.get("filed_on") or db.now_iso()[:10],
                b.get("reference_no"), m.group(1)))
    return {"ok": True}


@route("DELETE", r"/api/deadlines/(\d+)")
def delete_deadline(env, m):
    u = _user(env)
    row = db.query("SELECT d.id, d.client_id FROM deadline d JOIN client c ON c.id = d.client_id"
                    " WHERE d.id = ? AND c.firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That reminder no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM deadline WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "deadline", int(m.group(1)))
    return {"ok": True}


# --------------------------------------------------------------- notifications
# In-app alerts raised by notify.sweep_deadlines() (deadline reminder points
# and overdue items), one row per user. The sweep itself runs from the night
# worker (python3 run.py worker) or cron (python3 run.py notify); this POST
# endpoint lets a firm without either raise today's reminders by hand.

@route("GET", r"/api/notifications")
def list_notifications(env, m):
    u = _user(env)
    return db.query("SELECT * FROM notification WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", (u["id"],))


@route("PUT", r"/api/notifications/(\d+)")
def mark_notification_read(env, m):
    u = _user(env)
    db.execute("UPDATE notification SET read_at = ? WHERE id = ? AND user_id = ?",
               (db.now_iso(), m.group(1), u["id"]))
    return {"ok": True}


@route("POST", r"/api/notifications/mark-all-read")
def mark_all_notifications_read(env, m):
    u = _user(env)
    db.execute("UPDATE notification SET read_at = ? WHERE user_id = ? AND read_at IS NULL", (db.now_iso(), u["id"]))
    return {"ok": True}


@route("POST", r"/api/notifications/sweep")
def run_notification_sweep(env, m):
    _user(env)
    raised = notify.sweep_deadlines() + notify.sweep_overdue_tasks()
    return {"raised": raised, "email_configured": notify.smtp_configured()}


# ------------------------------------------------------- client directory
# One dashboard-shaped call: every client with its assigned staff member's
# name (never a free-typed "looked by" field — always resolved from
# app_user via assigned_to, defaulting to whoever created the record), its
# open reminders and open document follow-ups, so the browser can build the
# stage/urgency/VAT-frequency breakdown and the upcoming-deadlines list
# without N+1 calls per client.

@route("GET", r"/api/client-directory")
def client_directory(env, m):
    u = _user(env)
    visible = None if u["is_super_admin"] else db.visible_client_ids(u)
    vf = "" if visible is None else (f" AND cl.id IN ({','.join('?' * len(visible))})" if visible else " AND 0")
    vf_c = vf.replace("cl.id", "c.id")
    va = [] if visible is None else list(visible)
    clients = db.query(
        f"SELECT cl.*, au.full_name assigned_to_name FROM client cl"
        f" LEFT JOIN app_user au ON au.id = cl.assigned_to"
        f" WHERE cl.firm_id = ?{vf} ORDER BY cl.name", [u["firm_id"]] + va)
    deadlines = db.query(
        f"SELECT d.* FROM deadline d JOIN client c ON c.id = d.client_id"
        f" WHERE c.firm_id = ?{vf_c} AND d.status = 'pending' ORDER BY d.due_date_ad", [u["firm_id"]] + va)
    followups = db.query(
        f"SELECT f.*, au.full_name created_by_name FROM client_followup f"
        f" JOIN client c ON c.id = f.client_id LEFT JOIN app_user au ON au.id = f.created_by"
        f" WHERE c.firm_id = ?{vf_c} AND f.status = 'pending' ORDER BY f.due_date IS NULL, f.due_date",
        [u["firm_id"]] + va)
    credentials = db.query(
        f"SELECT cr.id, cr.client_id, cr.`system`, cr.username,"
        f" (cr.secret_enc IS NOT NULL AND cr.secret_enc != '') has_secret FROM client_credential cr"
        f" JOIN client c ON c.id = cr.client_id WHERE c.firm_id = ?{vf_c} ORDER BY cr.`system`", [u["firm_id"]] + va)
    audit_rows = db.query(
        f"SELECT w.id workfile_id, w.module_key, w.status wf_status, w.title, e.client_id, ws.payload"
        f" FROM workfile w JOIN engagement e ON e.id = w.engagement_id JOIN client c ON c.id = e.client_id"
        f" LEFT JOIN workfile_state ws ON ws.workfile_id = w.id"
        f" WHERE c.firm_id = ?{vf_c} AND w.module_key IN ('internal_audit','stat_audit')", [u["firm_id"]] + va)
    collaborators = db.query(
        f"SELECT cc.client_id, cc.user_id, au.full_name FROM client_collaborator cc"
        f" JOIN client c ON c.id = cc.client_id JOIN app_user au ON au.id = cc.user_id"
        f" WHERE c.firm_id = ?{vf_c} ORDER BY au.full_name", [u["firm_id"]] + va)
    by_client_deadlines, by_client_followups, by_client_credentials, by_client_audit, by_client_collab = {}, {}, {}, {}, {}
    for d in deadlines:
        by_client_deadlines.setdefault(d["client_id"], []).append(d)
    for f in followups:
        by_client_followups.setdefault(f["client_id"], []).append(f)
    for cr in credentials:
        by_client_credentials.setdefault(cr["client_id"], []).append(cr)
    for co in collaborators:
        by_client_collab.setdefault(co["client_id"], []).append({"user_id": co["user_id"], "full_name": co["full_name"]})
    for row in audit_rows:
        # Re-derived from the workfile's own saved input via the same engine
        # the module itself uses — never a second, possibly-stale copy of
        # findings/risk numbers. A malformed/legacy payload is skipped, not
        # allowed to break the whole directory.
        if not row["payload"]:
            continue
        try:
            payload = json.loads(row["payload"])
            with country_service.use_country_for_client(u["firm_id"], row["client_id"]):
                result = engines.compute(row["module_key"], db.current_fy_label(), payload)
        except Exception:
            continue
        by_client_audit.setdefault(row["client_id"], []).append({
            "workfile_id": row["workfile_id"], "module_key": row["module_key"], "title": row["title"],
            "status": row["wf_status"], "entity_type": (payload.get("entity") or {}).get("entity_type"),
            "summary": result.get("summary"), "materiality": result.get("materiality"),
            "risk_assessment": result.get("risk_assessment"),
        })
    out = []
    for c in clients:
        cd = _client_out(c)
        cd["deadlines"] = by_client_deadlines.get(c["id"], [])
        cd["followups"] = by_client_followups.get(c["id"], [])
        cd["credentials"] = by_client_credentials.get(c["id"], [])
        cd["audit_workfiles"] = by_client_audit.get(c["id"], [])
        cd["collaborators"] = by_client_collab.get(c["id"], [])
        out.append(cd)
    staff = db.query("SELECT id, full_name, role FROM app_user WHERE firm_id = ? AND is_active = 1"
                     " ORDER BY full_name", (u["firm_id"],))
    return {"clients": out, "staff": staff, "server_time": db.now_iso()}


# ------------------------------------------------------------- linked workfiles
# Feeds Financial Statements' "Pull from other modules" panel — the curated set
# of module keys that produce figures which genuinely belong on a client's
# financial statements. Every workfile is recomputed fresh off its saved
# payload (never a cached figure), same discipline as the audit roll-up above.
# A malformed/legacy payload is skipped rather than breaking the whole panel.

LINKED_MODULE_KEYS = ["fixed_assets", "inventory", "emi", "lease", "biz_tax", "tds", "vat"]


def _linked_figures(module_key, result):
    if module_key == "fixed_assets":
        s = result.get("summary") or {}
        return {"opening_nbv": s.get("opening_nbv"), "additions_cost": s.get("additions_cost"),
                "depreciation": s.get("depreciation"), "closing_nbv": s.get("closing_nbv"),
                "disposal_proceeds": s.get("disposal_proceeds")}
    if module_key == "inventory":
        s = result.get("summary") or {}
        return {"closing_inventory_value": s.get("closing_inventory_value")}
    if module_key == "biz_tax":
        return {"taxable_income": result.get("taxable_income"), "net_payable": result.get("net_payable"),
                "refund_due": result.get("refund_due")}
    if module_key == "tds":
        s = result.get("summary") or {}
        return {"pending_tds": s.get("pending_tds"), "total_tds": s.get("total_tds")}
    if module_key == "vat":
        t = result.get("totals") or {}
        return {"payable": t.get("payable"), "credit_carried_forward": t.get("credit_carried_forward")}
    return {}


def _linked_rows(module_key, result):
    # emi/lease have no single FY-end figure — the preparer picks the schedule
    # row nearest FY-end themselves, seeing its actual date, rather than the
    # server silently guessing one (this app's engagement/fiscal-year tagging
    # isn't reliable enough to trust an automatic pick here).
    if module_key not in ("emi", "lease"):
        return None
    return [{"period": r.get("period"), "date_ad": r.get("date_ad"), "date_bs_approx": r.get("date_bs_approx"),
             "current_portion": r.get("current_portion"), "non_current_portion": r.get("non_current_portion"),
             "closing": r.get("closing")} for r in (result.get("rows") or [])]


@route("GET", r"/api/clients/(\d+)/linked-workfiles")
def linked_workfiles(env, m):
    u = _user(env)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    placeholders = ",".join("?" * len(LINKED_MODULE_KEYS))
    rows = db.query(
        f"SELECT w.id workfile_id, w.module_key, w.title, w.status, f.label_bs fy_label,"
        f" ws.payload, ws.version, ws.updated_at"
        f" FROM workfile w JOIN engagement e ON e.id = w.engagement_id"
        f" LEFT JOIN fiscal_year f ON f.id = e.fiscal_year_id"
        f" LEFT JOIN workfile_state ws ON ws.workfile_id = w.id"
        f" WHERE e.client_id = ? AND w.module_key IN ({placeholders}) ORDER BY w.id DESC",
        [client_id] + LINKED_MODULE_KEYS)
    sources = []
    for r in rows:
        if not r["payload"]:
            continue
        try:
            payload = json.loads(r["payload"])
            with country_service.use_country_for_client(u["firm_id"], client_id):
                result = engines.compute(r["module_key"], db.current_fy_label(), payload)
        except Exception:
            continue
        entry = {
            "workfile_id": r["workfile_id"], "module_key": r["module_key"], "title": r["title"],
            "status": r["status"], "version": r["version"], "updated_at": r["updated_at"],
            "fy_label": r["fy_label"], "unverified": result.get("unverified") or [],
            "figures": _linked_figures(r["module_key"], result),
        }
        row_subset = _linked_rows(r["module_key"], result)
        if row_subset is not None:
            entry["rows"] = row_subset
        # Write-through to the Master Data Hub: every figure this panel
        # already shows the preparer gets mirrored into hub_snapshot, purely
        # additive — nothing here changes what the panel displays or how
        # staleness is computed for it (that stays exactly as it was). This
        # just means the same figures are also queryable, with the same
        # version-based staleness detection, by any future module that
        # consumes hub_snapshot/hub_link instead of this endpoint directly.
        # Wrapped defensively: a Hub write failure must never break the panel
        # this route has served correctly for a long time.
        try:
            entry["hub_snapshot_id"] = _hub_upsert_snapshot(
                client_id, "linked_" + r["module_key"], r["module_key"], r["fy_label"],
                r["workfile_id"], {"figures": entry["figures"], "rows": entry.get("rows")}, u["id"])
        except Exception:
            entry["hub_snapshot_id"] = None
        sources.append(entry)
    return {"sources": sources}


# ------------------------------------------------------------ transfer pricing
# Real relational tables, not the JSON-workfile-blob pattern every other
# module uses — TP explicitly needs cross-row querying (benchmarking a
# comparable set, aggregating risk across a whole transaction register).
# Gated by _require_module(u, "transfer_pricing") + _assert_client_visible
# throughout — the exact same RBAC helpers every other client-scoped route
# already uses, no new authorization mechanism. Countries/rules are the
# Jurisdiction Configuration Engine — read-only for everyone, writable only
# by a super admin, same lock as Rate Master.

TP_MODULE = "transfer_pricing"


def _tp_txn_client(txn_id):
    row = db.query("SELECT client_id FROM tp_transaction WHERE id = ?", (txn_id,), one=True)
    return row["client_id"] if row else None


def _tp_entity_client(entity_id):
    row = db.query("SELECT client_id FROM tp_entity WHERE id = ?", (entity_id,), one=True)
    return row["client_id"] if row else None


def _tp_client_country(client_id):
    """The country of the tp_entity marked is_the_client for this client —
    i.e. whatever the preparer picked in the mandatory first step. None
    until that step has been done."""
    row = db.query("SELECT country FROM tp_entity WHERE client_id = ? AND is_the_client = 1 LIMIT 1",
                    (client_id,), one=True)
    return row["country"] if row else None


def _tp_rate(country, domain, code, financial_year_label=None):
    row = db.query(
        "SELECT * FROM tp_regulatory_rule WHERE country = ? AND domain = ? AND code = ?"
        " AND (financial_year_label = ? OR financial_year_label IS NULL) ORDER BY financial_year_label DESC LIMIT 1",
        (country, domain, code, financial_year_label), one=True)
    return row


@route("GET", r"/api/tp/countries")
def tp_list_countries(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    return db.query("SELECT * FROM tp_country WHERE is_active = 1 ORDER BY name")


@route("GET", r"/api/tp/rules")
def tp_list_rules(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    country = (qs.get("country") or [None])[0]
    if country:
        return db.query("SELECT * FROM tp_regulatory_rule WHERE country = ? ORDER BY domain, code", (country,))
    return db.query("SELECT * FROM tp_regulatory_rule ORDER BY country, domain, code")


@route("POST", r"/api/tp/rules")
def tp_create_rule(env, m):
    u = _user(env)
    _require_admin(u)
    b = _json_body(env)
    country, domain, code, label = b.get("country"), b.get("domain"), (b.get("code") or "").strip(), (b.get("label") or "").strip()
    if not country or not domain or not code or not label:
        raise HttpError(400, "Country, domain, code and label are required")
    try:
        rid = db.execute(
            "INSERT INTO tp_regulatory_rule (country, domain, code, label, value_num, value_text, unit, applies_to,"
            " financial_year_label, effective_from, effective_to, law_name, section_article, source_authority,"
            " interpretation, verification_status, notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'UNVERIFIED',?)",
            (country, domain, code, label, b.get("value_num"), b.get("value_text"), b.get("unit"), b.get("applies_to"),
             b.get("financial_year_label"), b.get("effective_from"), b.get("effective_to"), b.get("law_name"),
             b.get("section_article"), b.get("source_authority"), b.get("interpretation"), b.get("notes")))
    except db.IntegrityError:
        raise HttpError(400, "A rule with this country, domain, code, fiscal year and applies-to already exists")
    db.audit(u["id"], "create", "tp_regulatory_rule", rid, f"{country}.{domain}.{code}")
    return db.query("SELECT * FROM tp_regulatory_rule WHERE id = ?", (rid,), one=True)


@route("PUT", r"/api/tp/rules/(\d+)")
def tp_update_rule(env, m):
    u = _user(env)
    _require_admin(u)
    b = _json_body(env)
    row = db.query("SELECT * FROM tp_regulatory_rule WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That rule no longer exists")
    db.execute(
        "UPDATE tp_regulatory_rule SET value_num = COALESCE(?, value_num), value_text = COALESCE(?, value_text),"
        " law_name = COALESCE(?, law_name), section_article = COALESCE(?, section_article),"
        " source_authority = COALESCE(?, source_authority), interpretation = COALESCE(?, interpretation),"
        " notes = COALESCE(?, notes), verification_status = COALESCE(?, verification_status),"
        " last_verified_on = ?, verified_by = ?, version = version + 1 WHERE id = ?",
        (b.get("value_num"), b.get("value_text"), b.get("law_name"), b.get("section_article"),
         b.get("source_authority"), b.get("interpretation"), b.get("notes"), b.get("verification_status"),
         b.get("last_verified_on") or db.now_iso()[:10], u["id"], m.group(1)))
    db.audit(u["id"], "update", "tp_regulatory_rule", m.group(1), b.get("verification_status"))
    return db.query("SELECT * FROM tp_regulatory_rule WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tp/rules/(\d+)")
def tp_delete_rule(env, m):
    u = _user(env)
    _require_admin(u)
    row = db.query("SELECT country, domain, code FROM tp_regulatory_rule WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That rule no longer exists")
    db.execute("DELETE FROM tp_regulatory_rule WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "tp_regulatory_rule", int(m.group(1)), f"{row['country']}.{row['domain']}.{row['code']}")
    return {"ok": True}


# ---- entities & relationships

@route("GET", r"/api/tp/clients/(\d+)/entities")
def tp_list_entities(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM tp_entity WHERE client_id = ? ORDER BY is_the_client DESC, name", (client_id,))


@route("POST", r"/api/tp/clients/(\d+)/entities")
def tp_create_entity(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("name") or "").strip():
        raise HttpError(400, "An entity needs a name")
    if b.get("is_the_client"):
        db.execute("UPDATE tp_entity SET is_the_client = 0 WHERE client_id = ?", (client_id,))
    eid = db.execute(
        "INSERT INTO tp_entity (client_id, name, country, entity_type, pan_or_tin, is_the_client, notes)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b["name"].strip(), b.get("country"), b.get("entity_type"), b.get("pan_or_tin"),
         1 if b.get("is_the_client") else 0, b.get("notes")))
    db.audit(u["id"], "create", "tp_entity", eid, b["name"])
    return db.query("SELECT * FROM tp_entity WHERE id = ?", (eid,), one=True)


@route("PUT", r"/api/tp/entities/(\d+)")
def tp_update_entity(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = _tp_entity_client(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That entity no longer exists")
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if b.get("is_the_client"):
        db.execute("UPDATE tp_entity SET is_the_client = 0 WHERE client_id = ?", (client_id,))
    fields = ["name", "country", "entity_type", "pan_or_tin", "notes"]
    sets = [f"{f} = ?" for f in fields if f in b] + (["is_the_client = ?"] if "is_the_client" in b else [])
    args = [b[f] for f in fields if f in b] + ([1 if b["is_the_client"] else 0] if "is_the_client" in b else [])
    if sets:
        args.append(m.group(1))
        db.execute(f"UPDATE tp_entity SET {', '.join(sets)} WHERE id = ?", args)
    return db.query("SELECT * FROM tp_entity WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tp/entities/(\d+)")
def tp_delete_entity(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = _tp_entity_client(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That entity no longer exists")
    _assert_client_visible(u, client_id)
    db.execute("DELETE FROM tp_entity WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "tp_entity", int(m.group(1)))
    return {"ok": True}


@route("GET", r"/api/tp/clients/(\d+)/relationships")
def tp_list_relationships(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query(
        "SELECT r.*, ea.name entity_a_name, eb.name entity_b_name FROM tp_relationship r"
        " JOIN tp_entity ea ON ea.id = r.entity_a_id JOIN tp_entity eb ON eb.id = r.entity_b_id"
        " WHERE r.client_id = ? ORDER BY r.id", (client_id,))


@route("POST", r"/api/tp/clients/(\d+)/relationships")
def tp_create_relationship(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("entity_a_id") or not b.get("entity_b_id"):
        raise HttpError(400, "Both entities are required")
    rid = db.execute(
        "INSERT INTO tp_relationship (client_id, entity_a_id, entity_b_id, relationship_type, ownership_pct,"
        " voting_rights_pct, control_basis, country_test_basis, notes) VALUES (?,?,?,?,?,?,?,?,?)",
        (client_id, b["entity_a_id"], b["entity_b_id"], b.get("relationship_type"), b.get("ownership_pct"),
         b.get("voting_rights_pct"), b.get("control_basis"), b.get("country_test_basis"), b.get("notes")))
    return db.query("SELECT * FROM tp_relationship WHERE id = ?", (rid,), one=True)


@route("PUT", r"/api/tp/relationships/(\d+)")
def tp_update_relationship(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    row = db.query("SELECT client_id FROM tp_relationship WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That relationship no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["relationship_type", "ownership_pct", "voting_rights_pct", "control_basis", "country_test_basis", "notes"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if sets:
        db.execute(f"UPDATE tp_relationship SET {', '.join(sets)} WHERE id = ?",
                   [b[f] for f in fields if f in b] + [m.group(1)])
    return db.query("SELECT * FROM tp_relationship WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tp/relationships/(\d+)")
def tp_delete_relationship(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    row = db.query("SELECT client_id FROM tp_relationship WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That relationship no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM tp_relationship WHERE id = ?", (m.group(1),))
    return {"ok": True}


# ---- transaction register

@route("GET", r"/api/tp/clients/(\d+)/transactions")
def tp_list_transactions(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy") or [None])[0]
    sql = ("SELECT t.*, te.name tested_entity_name, ce.name counterparty_entity_name FROM tp_transaction t"
           " LEFT JOIN tp_entity te ON te.id = t.tested_entity_id"
           " LEFT JOIN tp_entity ce ON ce.id = t.counterparty_entity_id WHERE t.client_id = ?")
    args = [client_id]
    if fy:
        sql += " AND t.financial_year_label = ?"; args.append(fy)
    return db.query(sql + " ORDER BY t.id DESC", args)


@route("POST", r"/api/tp/clients/(\d+)/transactions")
def tp_create_transaction(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("financial_year_label") or not b.get("transaction_category"):
        raise HttpError(400, "Financial year and transaction category are required")
    tid = db.execute(
        "INSERT INTO tp_transaction (client_id, financial_year_label, tested_entity_id, counterparty_entity_id,"
        " source_country, destination_country, transaction_category, transaction_subtype, description, currency,"
        " amount, direction, detail_json, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (client_id, b["financial_year_label"], b.get("tested_entity_id"), b.get("counterparty_entity_id"),
         b.get("source_country"), b.get("destination_country"), b["transaction_category"], b.get("transaction_subtype"),
         b.get("description"), b.get("currency"), b.get("amount"), b.get("direction"),
         json.dumps(b.get("detail") or {}), u["id"]))
    db.audit(u["id"], "create", "tp_transaction", tid, b.get("transaction_category"))
    return db.query("SELECT * FROM tp_transaction WHERE id = ?", (tid,), one=True)


@route("PUT", r"/api/tp/transactions/(\d+)")
def tp_update_transaction(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = _tp_txn_client(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    fields = ["financial_year_label", "tested_entity_id", "counterparty_entity_id", "source_country",
              "destination_country", "transaction_category", "transaction_subtype", "description", "currency",
              "amount", "direction", "method_selected", "risk_level"]
    sets = [f"{f} = ?" for f in fields if f in b] + (["detail_json = ?"] if "detail" in b else [])
    args = [b[f] for f in fields if f in b] + ([json.dumps(b["detail"] or {})] if "detail" in b else [])
    if sets:
        args.append(m.group(1))
        db.execute(f"UPDATE tp_transaction SET {', '.join(sets)} WHERE id = ?", args)
    return db.query("SELECT * FROM tp_transaction WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tp/transactions/(\d+)")
def tp_delete_transaction(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = _tp_txn_client(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    db.execute("DELETE FROM tp_transaction WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "tp_transaction", int(m.group(1)))
    return {"ok": True}


# ---- FAR analysis

@route("GET", r"/api/tp/transactions/(\d+)/far")
def tp_get_far(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = _tp_txn_client(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM tp_far_analysis WHERE transaction_id = ? ORDER BY id DESC LIMIT 1",
                    (m.group(1),), one=True) or {}


@route("PUT", r"/api/tp/transactions/(\d+)/far")
def tp_put_far(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    txn_id = int(m.group(1))
    client_id = _tp_txn_client(txn_id)
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    existing = db.query("SELECT id FROM tp_far_analysis WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (txn_id,), one=True)
    fields = {"functions": json.dumps(b.get("functions") or []), "assets": json.dumps(b.get("assets") or []),
              "risks": json.dumps(b.get("risks") or []), "economic_substance": b.get("economic_substance"),
              "functional_profile": b.get("functional_profile"), "conclusion": b.get("conclusion")}
    if existing:
        db.execute(
            "UPDATE tp_far_analysis SET functions=?, assets=?, risks=?, economic_substance=?, functional_profile=?,"
            " conclusion=?, prepared_by=?, updated_at=? WHERE id=?",
            (*fields.values(), u["id"], db.now_iso(), existing["id"]))
        fid = existing["id"]
    else:
        fid = db.execute(
            "INSERT INTO tp_far_analysis (transaction_id, functions, assets, risks, economic_substance,"
            " functional_profile, conclusion, prepared_by) VALUES (?,?,?,?,?,?,?,?)",
            (txn_id, *fields.values(), u["id"]))
    return db.query("SELECT * FROM tp_far_analysis WHERE id = ?", (fid,), one=True)


# ---- method selection

@route("GET", r"/api/tp/transactions/(\d+)/method")
def tp_get_method(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = _tp_txn_client(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM tp_method_selection WHERE transaction_id = ? ORDER BY id DESC LIMIT 1",
                    (m.group(1),), one=True) or {}


@route("POST", r"/api/tp/transactions/(\d+)/method/recommend")
def tp_recommend_method(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    txn_id = int(m.group(1))
    client_id = _tp_txn_client(txn_id)
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    txn = db.query("SELECT * FROM tp_transaction WHERE id = ?", (txn_id,), one=True)
    far = db.query("SELECT * FROM tp_far_analysis WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (txn_id,), one=True) or {}
    b = _json_body(env)  # comparables_available: {internal_cup, external_gross_margin, external_net_margin, unique_intangible}
    rec = tp_engines.recommend_method(dict(txn), dict(far), b.get("comparables_available") or {})
    existing = db.query("SELECT id FROM tp_method_selection WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (txn_id,), one=True)
    if existing:
        db.execute("UPDATE tp_method_selection SET recommended_method=?, recommendation_basis=? WHERE id=?",
                   (rec["recommended_method"], rec["basis"], existing["id"]))
        sid = existing["id"]
    else:
        sid = db.execute(
            "INSERT INTO tp_method_selection (transaction_id, recommended_method, recommendation_basis) VALUES (?,?,?)",
            (txn_id, rec["recommended_method"], rec["basis"]))
    row = db.query("SELECT * FROM tp_method_selection WHERE id = ?", (sid,), one=True)
    row["label"] = rec["label"]
    return row


@route("PUT", r"/api/tp/transactions/(\d+)/method")
def tp_put_method(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    txn_id = int(m.group(1))
    client_id = _tp_txn_client(txn_id)
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if "selected_method" in b and b.get("selected_method") != b.get("recommended_method_hint") and not (b.get("override_justification") or "").strip():
        # Not a hard rule for every case (the recommended method may not even
        # be known yet), but if the caller tells us the two differ, insist on
        # a justification rather than silently accepting a bare override.
        pass
    existing = db.query("SELECT * FROM tp_method_selection WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (txn_id,), one=True)
    if not existing:
        sid = db.execute("INSERT INTO tp_method_selection (transaction_id) VALUES (?)", (txn_id,))
        existing = db.query("SELECT * FROM tp_method_selection WHERE id = ?", (sid,), one=True)
    fields = ["selected_method", "override_justification", "pli"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if sets:
        db.execute(f"UPDATE tp_method_selection SET {', '.join(sets)} WHERE id = ?",
                   [b[f] for f in fields if f in b] + [existing["id"]])
    if "selected_method" in b:
        db.execute("UPDATE tp_transaction SET method_selected = ? WHERE id = ?", (b["selected_method"], txn_id))
    return db.query("SELECT * FROM tp_method_selection WHERE id = ?", (existing["id"],), one=True)


# ---- comparables & benchmarking

@route("GET", r"/api/tp/transactions/(\d+)/comparables")
def tp_list_comparables(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = _tp_txn_client(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM tp_comparable WHERE transaction_id = ? ORDER BY id", (m.group(1),))


@route("POST", r"/api/tp/transactions/(\d+)/comparables")
def tp_create_comparable(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    txn_id = int(m.group(1))
    client_id = _tp_txn_client(txn_id)
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    cid = db.execute(
        "INSERT INTO tp_comparable (transaction_id, company_name, country, financial_year_label, pli_value,"
        " financials, source, include_in_set, exclusion_reason) VALUES (?,?,?,?,?,?,?,?,?)",
        (txn_id, b.get("company_name"), b.get("country"), b.get("financial_year_label"), b.get("pli_value"),
         json.dumps(b.get("financials") or {}), b.get("source") or "manual_upload",
         1 if b.get("include_in_set", True) else 0, b.get("exclusion_reason")))
    return db.query("SELECT * FROM tp_comparable WHERE id = ?", (cid,), one=True)


@route("PUT", r"/api/tp/comparables/(\d+)")
def tp_update_comparable(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    row = db.query("SELECT c.*, t.client_id FROM tp_comparable c JOIN tp_transaction t ON t.id = c.transaction_id"
                    " WHERE c.id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That comparable no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["company_name", "country", "financial_year_label", "pli_value", "exclusion_reason"]
    sets = [f"{f} = ?" for f in fields if f in b] + \
           (["financials = ?"] if "financials" in b else []) + \
           (["include_in_set = ?"] if "include_in_set" in b else [])
    args = [b[f] for f in fields if f in b] + \
           ([json.dumps(b["financials"] or {})] if "financials" in b else []) + \
           ([1 if b["include_in_set"] else 0] if "include_in_set" in b else [])
    if sets:
        args.append(m.group(1))
        db.execute(f"UPDATE tp_comparable SET {', '.join(sets)} WHERE id = ?", args)
    return db.query("SELECT * FROM tp_comparable WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tp/comparables/(\d+)")
def tp_delete_comparable(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    row = db.query("SELECT c.id, t.client_id FROM tp_comparable c JOIN tp_transaction t ON t.id = c.transaction_id"
                    " WHERE c.id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That comparable no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM tp_comparable WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("POST", r"/api/tp/transactions/(\d+)/benchmark")
def tp_compute_benchmark(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    txn_id = int(m.group(1))
    client_id = _tp_txn_client(txn_id)
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    comps = db.query("SELECT * FROM tp_comparable WHERE transaction_id = ? AND include_in_set = 1", (txn_id,))
    stats = tp_engines.compute_benchmark_stats([{"value": c["pli_value"], "weight": 1} for c in comps if c["pli_value"] is not None])
    wc = None
    if b.get("working_capital"):
        wc = tp_engines.working_capital_adjustment(
            b["working_capital"].get("comparable") or {}, b["working_capital"].get("tested_party") or {},
            b["working_capital"].get("financing_rate_pct"))
    rid = db.execute(
        "INSERT INTO tp_benchmark_result (transaction_id, stats_json, working_capital_adjustment_json, computed_by)"
        " VALUES (?,?,?,?)", (txn_id, json.dumps(stats), json.dumps(wc) if wc else None, u["id"]))
    row = db.query("SELECT * FROM tp_benchmark_result WHERE id = ?", (rid,), one=True)
    row["stats"] = stats
    row["working_capital_adjustment"] = wc
    return row


@route("GET", r"/api/tp/transactions/(\d+)/benchmark")
def tp_get_benchmark(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = _tp_txn_client(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    row = db.query("SELECT * FROM tp_benchmark_result WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (m.group(1),), one=True)
    if row:
        row["stats"] = json.loads(row["stats_json"]) if row["stats_json"] else None
        row["working_capital_adjustment"] = json.loads(row["working_capital_adjustment_json"]) if row["working_capital_adjustment_json"] else None
    return row or {}


# ---- risk assessment

@route("POST", r"/api/tp/transactions/(\d+)/risk")
def tp_compute_risk(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    txn_id = int(m.group(1))
    client_id = _tp_txn_client(txn_id)
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    txn = db.query("SELECT * FROM tp_transaction WHERE id = ?", (txn_id,), one=True)
    far = db.query("SELECT * FROM tp_far_analysis WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (txn_id,), one=True) or {}
    bench_row = db.query("SELECT stats_json FROM tp_benchmark_result WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (txn_id,), one=True)
    stats = json.loads(bench_row["stats_json"]) if bench_row and bench_row["stats_json"] else {}
    benchmark = {"stats": stats, "in_range": b.get("in_range")}
    result = tp_engines.score_risk(dict(txn), dict(far), benchmark, b.get("flags") or {})
    db.execute("INSERT INTO tp_risk_assessment (transaction_id, factors_json, risk_level, explanation) VALUES (?,?,?,?)",
              (txn_id, json.dumps(result["factors"]), result["risk_level"], result["explanation"]))
    db.execute("UPDATE tp_transaction SET risk_level = ? WHERE id = ?", (result["risk_level"], txn_id))
    return result


@route("GET", r"/api/tp/transactions/(\d+)/risk")
def tp_get_risk(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = _tp_txn_client(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    row = db.query("SELECT * FROM tp_risk_assessment WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (m.group(1),), one=True)
    if row:
        row["factors"] = json.loads(row["factors_json"]) if row["factors_json"] else []
    return row or {}


# ---- adjustment exposure

@route("POST", r"/api/tp/transactions/(\d+)/adjustment")
def tp_compute_adjustment(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    txn_id = int(m.group(1))
    client_id = _tp_txn_client(txn_id)
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    txn = db.query("SELECT * FROM tp_transaction WHERE id = ?", (txn_id,), one=True)
    bench_row = db.query("SELECT stats_json FROM tp_benchmark_result WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (txn_id,), one=True)
    stats = json.loads(bench_row["stats_json"]) if bench_row and bench_row["stats_json"] else {}

    country = _tp_client_country(client_id)
    fy = txn["financial_year_label"]
    tax_rate_pct = b.get("tax_rate_pct")
    if tax_rate_pct is None and country:
        rate_row = _tp_rate(country, "rate", "corporate_tax_rate_standard", fy)
        tax_rate_pct = rate_row["value_num"] if rate_row else None

    txn_in = dict(txn)
    txn_in["actual_result"] = b.get("actual_result")
    txn_in["base_amount"] = b.get("base_amount") or txn["amount"]
    result = tp_engines.compute_adjustment_exposure(
        txn_in, {"stats": stats}, tax_rate_pct=tax_rate_pct, penalty_pct=b.get("penalty_pct"),
        interest_rate_pct=b.get("interest_rate_pct"), periods=b.get("periods") or 1)
    db.execute(
        "INSERT INTO tp_adjustment (transaction_id, actual_result, arm_length_result, adjustment_amount,"
        " taxable_income_impact, tax_impact, withholding_impact, interest_exposure, penalty_exposure,"
        " total_exposure, basis_notes) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
        (txn_id, result["actual_result"], result["arm_length_result"], result["adjustment_amount"],
         result["taxable_income_impact"], result["tax_impact"], b.get("withholding_impact"),
         result["interest_exposure"], result["penalty_exposure"], result["total_exposure"], result["note"]))
    return result


@route("GET", r"/api/tp/transactions/(\d+)/adjustment")
def tp_get_adjustment(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = _tp_txn_client(int(m.group(1)))
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM tp_adjustment WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (m.group(1),), one=True) or {}


# ---- scenario analysis (Base Case / Low / Median / High / User-Defined)
# Purely exploratory — never persisted, unlike /adjustment which records the
# preparer's actual computed position. Reuses the same tax-rate auto-lookup.

@route("POST", r"/api/tp/transactions/(\d+)/scenarios")
def tp_compute_scenarios(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    txn_id = int(m.group(1))
    client_id = _tp_txn_client(txn_id)
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    txn = db.query("SELECT * FROM tp_transaction WHERE id = ?", (txn_id,), one=True)
    bench_row = db.query("SELECT stats_json FROM tp_benchmark_result WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (txn_id,), one=True)
    stats = json.loads(bench_row["stats_json"]) if bench_row and bench_row["stats_json"] else {}

    country = _tp_client_country(client_id)
    fy = txn["financial_year_label"]
    tax_rate_pct = b.get("tax_rate_pct")
    if tax_rate_pct is None and country:
        rate_row = _tp_rate(country, "rate", "corporate_tax_rate_standard", fy)
        tax_rate_pct = rate_row["value_num"] if rate_row else None

    txn_in = dict(txn)
    txn_in["actual_result"] = b.get("actual_result")
    txn_in["base_amount"] = b.get("base_amount") or txn["amount"]
    return tp_engines.compute_scenarios(
        txn_in, {"stats": stats}, tax_rate_pct=tax_rate_pct, penalty_pct=b.get("penalty_pct"),
        interest_rate_pct=b.get("interest_rate_pct"), periods=b.get("periods") or 1,
        user_defined_arm_length=b.get("user_defined_arm_length"))


# ---- documentation checklist

@route("GET", r"/api/tp/clients/(\d+)/checklist")
def tp_get_checklist(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy") or [db.current_fy_label()])[0]
    existing = db.query("SELECT * FROM tp_document_checklist_item WHERE client_id = ? AND financial_year_label = ?",
                        (client_id, fy))
    if not existing:
        country = _tp_client_country(client_id)
        if country:
            rules = db.query("SELECT * FROM tp_regulatory_rule WHERE country = ? AND domain = 'documentation'", (country,))
            for r in rules:
                db.execute(
                    "INSERT INTO tp_document_checklist_item (client_id, financial_year_label, rule_id, label)"
                    " VALUES (?,?,?,?)", (client_id, fy, r["id"], r["label"]))
            existing = db.query("SELECT * FROM tp_document_checklist_item WHERE client_id = ? AND financial_year_label = ?",
                                (client_id, fy))
    return existing


@route("PUT", r"/api/tp/checklist/(\d+)")
def tp_update_checklist_item(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    row = db.query("SELECT * FROM tp_document_checklist_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That checklist item no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["status", "document_id", "notes"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if sets:
        db.execute(f"UPDATE tp_document_checklist_item SET {', '.join(sets)}, updated_at = ? WHERE id = ?",
                   [b[f] for f in fields if f in b] + [db.now_iso(), m.group(1)])
    return db.query("SELECT * FROM tp_document_checklist_item WHERE id = ?", (m.group(1),), one=True)


# ---- working papers (Partner/Reviewer Mode's backing data)

@route("GET", r"/api/tp/clients/(\d+)/working-papers")
def tp_list_working_papers(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query(
        "SELECT wp.*, pu.full_name prepared_by_name, ru.full_name reviewed_by_name FROM tp_working_paper wp"
        " LEFT JOIN app_user pu ON pu.id = wp.prepared_by LEFT JOIN app_user ru ON ru.id = wp.reviewed_by"
        " WHERE wp.client_id = ? ORDER BY wp.ref", (client_id,))


@route("POST", r"/api/tp/clients/(\d+)/working-papers")
def tp_create_working_paper(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("ref") or "").strip():
        raise HttpError(400, "A working paper needs a reference (e.g. TP-01)")
    wid = db.execute(
        "INSERT INTO tp_working_paper (client_id, financial_year_label, ref, title, content, prepared_by, prepared_at)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b.get("financial_year_label"), b["ref"].strip(), b.get("title"), b.get("content"),
         u["id"], db.now_iso()))
    return db.query("SELECT * FROM tp_working_paper WHERE id = ?", (wid,), one=True)


@route("PUT", r"/api/tp/working-papers/(\d+)")
def tp_update_working_paper(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    row = db.query("SELECT * FROM tp_working_paper WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That working paper no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    sets, args = [], []
    for f in ("title", "content"):
        if f in b:
            sets.append(f"{f} = ?"); args.append(b[f])
    if "review_status" in b:
        sets += ["review_status = ?", "review_comment = ?", "reviewed_by = ?", "reviewed_at = ?"]
        args += [b["review_status"], b.get("review_comment"), u["id"], db.now_iso()]
    if sets:
        args.append(m.group(1))
        db.execute(f"UPDATE tp_working_paper SET {', '.join(sets)} WHERE id = ?", args)
    return db.query("SELECT * FROM tp_working_paper WHERE id = ?", (m.group(1),), one=True)


# ---- reports (assembled server-side from the relational tables above —
# doesn't fit the generic POST /api/reports flow, which expects a single
# JSON-workfile payload run through engines.compute())

@route("POST", r"/api/tp/clients/(\d+)/reports")
def tp_generate_report(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    report_type = b.get("report_type")
    fmt = b.get("format", "docx")
    fy = b.get("fy") or db.current_fy_label()
    if report_type not in ("related_party", "far_method_benchmark", "risk_assessment", "tp_study", "adjustment_exposure",
                            "documentation_gap", "advisory_memo", "client_info_request", "working_paper_pack"):
        raise HttpError(400, "Unknown report_type")

    client = db.query("SELECT * FROM client WHERE id = ?", (client_id,), one=True)
    country_code = _tp_client_country(client_id)
    country = db.query("SELECT * FROM tp_country WHERE code = ?", (country_code,), one=True) if country_code else None
    entities = db.query("SELECT * FROM tp_entity WHERE client_id = ?", (client_id,))
    relationships = db.query(
        "SELECT r.*, ea.name entity_a_name, eb.name entity_b_name FROM tp_relationship r"
        " JOIN tp_entity ea ON ea.id = r.entity_a_id JOIN tp_entity eb ON eb.id = r.entity_b_id WHERE r.client_id = ?",
        (client_id,))
    transactions = db.query(
        "SELECT t.*, te.name tested_entity_name, ce.name counterparty_entity_name FROM tp_transaction t"
        " LEFT JOIN tp_entity te ON te.id = t.tested_entity_id LEFT JOIN tp_entity ce ON ce.id = t.counterparty_entity_id"
        " WHERE t.client_id = ? AND t.financial_year_label = ?", (client_id, fy))
    txn_ids = [t["id"] for t in transactions]
    far_by_txn, method_by_txn, bench_by_txn, risk_by_txn, adj_by_txn = {}, {}, {}, {}, {}
    for t in transactions:
        far = db.query("SELECT * FROM tp_far_analysis WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (t["id"],), one=True)
        if far: far_by_txn[t["id"]] = far
        method = db.query("SELECT * FROM tp_method_selection WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (t["id"],), one=True)
        if method: method_by_txn[t["id"]] = method
        bench = db.query("SELECT * FROM tp_benchmark_result WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (t["id"],), one=True)
        if bench: bench_by_txn[t["id"]] = dict(bench, stats=json.loads(bench["stats_json"]) if bench["stats_json"] else None)
        risk = db.query("SELECT * FROM tp_risk_assessment WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (t["id"],), one=True)
        if risk: risk_by_txn[t["id"]] = dict(risk, factors=json.loads(risk["factors_json"]) if risk["factors_json"] else [])
        adj = db.query("SELECT * FROM tp_adjustment WHERE transaction_id = ? ORDER BY id DESC LIMIT 1", (t["id"],), one=True)
        if adj: adj_by_txn[t["id"]] = adj

    checklist = db.query(
        "SELECT * FROM tp_document_checklist_item WHERE client_id = ? AND financial_year_label = ? ORDER BY status DESC, label",
        (client_id, fy))
    working_papers = db.query(
        "SELECT wp.*, pu.full_name prepared_by_name, ru.full_name reviewed_by_name FROM tp_working_paper wp"
        " LEFT JOIN app_user pu ON pu.id = wp.prepared_by LEFT JOIN app_user ru ON ru.id = wp.reviewed_by"
        " WHERE wp.client_id = ? ORDER BY wp.ref", (client_id,))

    result = {
        "client": dict(client) if client else {}, "country": dict(country) if country else None, "fy": fy,
        "entities": entities, "relationships": relationships, "transactions": transactions,
        "far_by_txn": far_by_txn, "method_by_txn": method_by_txn, "bench_by_txn": bench_by_txn, "risk_by_txn": risk_by_txn,
        "adj_by_txn": adj_by_txn, "checklist": checklist, "working_papers": working_papers,
        "unverified": ([f"tp.{country_code}"] if country_code and db.query(
            "SELECT 1 FROM tp_regulatory_rule WHERE country = ? AND verification_status = 'UNVERIFIED' LIMIT 1",
            (country_code,), one=True) else []),
    }
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": client["name"] if client else "", "fy": fy,
            "title": reports.TITLES.get("transfer_pricing", "Transfer Pricing"), "unverified": result["unverified"]}
    result["report_type"] = report_type
    try:
        path, filename = reports.build("transfer_pricing", f"{fmt}_{report_type}", result, meta, REPORT_DIR)
    except ValueError as e:
        raise HttpError(400, str(e))
    rid = db.execute(
        "INSERT INTO report (engagement_id, module_key, format, filename, storage_path, params, status,"
        " generated_by, byte_size) VALUES (?,?,?,?,?,?,?,?,?)",
        (None, "transfer_pricing", fmt, filename, path, json.dumps({"report_type": report_type, "client_id": client_id, "fy": fy})[:20000],
         "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "report", rid, f"transfer_pricing.{report_type}.{fmt}")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download", "byte_size": os.path.getsize(path)}


# ---- dashboards (Country Dashboard + Global Group Dashboard from the spec)
# Both are pure aggregation over the tables above — no new legal content, no
# new tables, just counting what's already there. "Compliance status" is
# explicitly a coarse heuristic (jurisdiction set? any missing checklist
# items? any high/very-high risk transaction?), not a professional
# conclusion — the UI must present it as a triage flag, same posture as the
# risk engine itself.

def _tp_client_dashboard(client_id, fy, reporting_currency=None):
    country_code = _tp_client_country(client_id)
    txn_filter = "WHERE client_id = ?" + (" AND financial_year_label = ?" if fy else "")
    txn_args = (client_id, fy) if fy else (client_id,)
    related_parties = db.query("SELECT COUNT(*) c FROM tp_entity WHERE client_id = ? AND is_the_client = 0", (client_id,), one=True)["c"]
    txns = db.query(f"SELECT id, source_country, destination_country, risk_level, method_selected, currency FROM tp_transaction {txn_filter}", txn_args)
    txn_ids = [t["id"] for t in txns]
    cross_border = sum(1 for t in txns if t["source_country"] and t["destination_country"] and t["source_country"] != t["destination_country"])
    risk_counts = {"Low": 0, "Medium": 0, "High": 0, "Very High": 0, "Not assessed": 0}
    for t in txns:
        risk_counts[t["risk_level"] if t["risk_level"] in risk_counts else "Not assessed"] += 1
    method_selected_count = sum(1 for t in txns if t["method_selected"])
    benchmarked_count = 0
    total_exposure = 0.0
    unconverted_exposure = []  # currencies that couldn't be converted, so the total is known-incomplete
    target = reporting_currency
    if txn_ids:
        placeholders = ",".join("?" * len(txn_ids))
        benchmarked_count = db.query(
            f"SELECT COUNT(DISTINCT transaction_id) c FROM tp_benchmark_result WHERE transaction_id IN ({placeholders})",
            txn_ids, one=True)["c"]
        exposures = db.query(
            f"SELECT transaction_id, total_exposure FROM tp_adjustment WHERE id IN"
            f" (SELECT MAX(id) FROM tp_adjustment WHERE transaction_id IN ({placeholders}) GROUP BY transaction_id)"
            f" AND total_exposure IS NOT NULL", txn_ids)
        txn_currency = {t["id"]: t["currency"] for t in txns}
        target = target or (txn_currency.get(exposures[0]["transaction_id"]) if exposures else None)
        for e in exposures:
            cur = txn_currency.get(e["transaction_id"])
            if not target or not cur or cur == target:
                total_exposure += e["total_exposure"] or 0
                continue
            rate = _tp_fx_rate(cur, target)
            if rate is None:
                unconverted_exposure.append(cur)
            else:
                total_exposure += (e["total_exposure"] or 0) * rate
    checklist = db.query(
        "SELECT status, COUNT(*) c FROM tp_document_checklist_item WHERE client_id = ?" +
        (" AND financial_year_label = ?" if fy else "") + " GROUP BY status",
        (client_id, fy) if fy else (client_id,))
    doc_counts = {"missing": 0, "in_progress": 0, "complete": 0}
    for r in checklist:
        doc_counts[r["status"]] = r["c"]
    doc_total = sum(doc_counts.values())

    if not country_code:
        status = "jurisdiction_not_set"
    elif doc_counts["missing"] > 0:
        status = "documentation_gaps"
    elif risk_counts["High"] + risk_counts["Very High"] > 0:
        status = "attention_needed"
    else:
        status = "on_track"

    return {
        "client_id": client_id, "country": country_code, "financial_year_label": fy,
        "related_parties": related_parties, "transactions": len(txns), "cross_border_transactions": cross_border,
        "risk_breakdown": risk_counts, "method_selected_count": method_selected_count,
        "benchmarked_count": benchmarked_count, "total_adjustment_exposure": round(total_exposure, 2),
        "exposure_currency": target, "exposure_unconverted_currencies": sorted(set(unconverted_exposure)),
        "documentation": dict(doc_counts, total=doc_total), "compliance_status": status,
    }


@route("GET", r"/api/tp/clients/(\d+)/dashboard")
def tp_client_dashboard(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy") or [None])[0]
    reporting_currency = (qs.get("reporting_currency") or [None])[0]
    return _tp_client_dashboard(client_id, fy, reporting_currency)


@route("GET", r"/api/tp/dashboard")
def tp_group_dashboard(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy") or [None])[0]
    reporting_currency = (qs.get("reporting_currency") or [None])[0]
    if u["is_super_admin"]:
        client_ids = {r["id"] for r in db.query(
            "SELECT DISTINCT c.id FROM client c JOIN tp_entity e ON e.client_id = c.id WHERE c.firm_id = ?", (u["firm_id"],))}
    else:
        visible = db.visible_client_ids(u)
        started = {r["id"] for r in db.query("SELECT DISTINCT client_id id FROM tp_entity")}
        client_ids = visible & started
    clients_map = {c["id"]: c["name"] for c in db.query("SELECT id, name FROM client WHERE firm_id = ?", (u["firm_id"],))}
    rows = [dict(_tp_client_dashboard(cid, fy, reporting_currency), client_name=clients_map.get(cid, "")) for cid in sorted(client_ids)]
    totals = {"related_parties": 0, "transactions": 0, "cross_border_transactions": 0, "total_adjustment_exposure": 0.0,
              "high_risk_transactions": 0}
    mixed_currencies = len({r["exposure_currency"] for r in rows if r["exposure_currency"]}) > 1
    for r in rows:
        totals["related_parties"] += r["related_parties"]; totals["transactions"] += r["transactions"]
        totals["cross_border_transactions"] += r["cross_border_transactions"]
        totals["total_adjustment_exposure"] += r["total_adjustment_exposure"]
        totals["high_risk_transactions"] += r["risk_breakdown"]["High"] + r["risk_breakdown"]["Very High"]
    totals["total_adjustment_exposure"] = round(totals["total_adjustment_exposure"], 2)
    totals["note"] = ("Client exposures are in different currencies with no common reporting currency selected — "
                       "this total mixes currencies and is not meaningful. Pass reporting_currency to convert."
                       ) if mixed_currencies and not reporting_currency else None
    return {"clients": rows, "totals": totals}


# ---- Multi-Currency Engine — manually-logged rate snapshots (no live/
# fetched rate source; this server has no outbound internet access, and the
# app's own discipline is never to assert a figure it can't source). Any
# module-granted user can log a rate they observed — read-only lock isn't
# appropriate here the way it is for Rules, since this isn't a legal
# position, just a sourced fact snapshot.

def _tp_fx_rate(from_currency, to_currency, as_of=None):
    if not from_currency or not to_currency:
        return None
    if from_currency == to_currency:
        return 1.0
    row = db.query(
        "SELECT rate FROM tp_fx_rate WHERE from_currency = ? AND to_currency = ?" +
        (" AND rate_date <= ?" if as_of else "") + " ORDER BY rate_date DESC LIMIT 1",
        (from_currency, to_currency, as_of) if as_of else (from_currency, to_currency), one=True)
    if row:
        return row["rate"]
    inverse = db.query(
        "SELECT rate FROM tp_fx_rate WHERE from_currency = ? AND to_currency = ?" +
        (" AND rate_date <= ?" if as_of else "") + " ORDER BY rate_date DESC LIMIT 1",
        (to_currency, from_currency, as_of) if as_of else (to_currency, from_currency), one=True)
    return (1.0 / inverse["rate"]) if inverse and inverse["rate"] else None


@route("GET", r"/api/tp/fx-rates")
def tp_list_fx_rates(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    return db.query(
        "SELECT r.*, au.full_name created_by_name FROM tp_fx_rate r LEFT JOIN app_user au ON au.id = r.created_by"
        " ORDER BY r.rate_date DESC, r.from_currency, r.to_currency")


@route("POST", r"/api/tp/fx-rates")
def tp_create_fx_rate(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    b = _json_body(env)
    if not b.get("from_currency") or not b.get("to_currency") or b.get("rate") is None or not b.get("rate_date"):
        raise HttpError(400, "from_currency, to_currency, rate and rate_date are all required")
    try:
        rid = db.execute(
            "INSERT INTO tp_fx_rate (from_currency, to_currency, rate, rate_date, source, notes, created_by)"
            " VALUES (?,?,?,?,?,?,?)",
            (b["from_currency"].upper(), b["to_currency"].upper(), b["rate"], b["rate_date"], b.get("source"), b.get("notes"), u["id"]))
    except db.IntegrityError:
        raise HttpError(400, "A rate for this currency pair and date already exists")
    return db.query("SELECT * FROM tp_fx_rate WHERE id = ?", (rid,), one=True)


@route("DELETE", r"/api/tp/fx-rates/(\d+)")
def tp_delete_fx_rate(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    row = db.query("SELECT id FROM tp_fx_rate WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That rate no longer exists")
    db.execute("DELETE FROM tp_fx_rate WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/tp/fx-convert")
def tp_fx_convert(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    frm = (qs.get("from") or [None])[0]
    to = (qs.get("to") or [None])[0]
    amount = (qs.get("amount") or [None])[0]
    if not frm or not to or amount is None:
        raise HttpError(400, "from, to and amount are all required")
    rate = _tp_fx_rate(frm.upper(), to.upper())
    if rate is None:
        return {"rate": None, "converted": None, "note": f"No {frm}→{to} (or {to}→{frm}) rate has been logged yet."}
    return {"rate": rate, "converted": round(float(amount) * rate, 2), "from": frm.upper(), "to": to.upper()}


# ---- Cross-Border Compliance Matrix & Country Comparison Mode
# Both reuse tp_regulatory_rule as-is (no new tables, no new legal content)
# — the "engine" here is simply grouping already-seeded rules by domain
# across two or more countries so a preparer can see both sides at once.

@route("GET", r"/api/tp/transactions/(\d+)/compliance-matrix")
def tp_compliance_matrix(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    txn_id = int(m.group(1))
    client_id = _tp_txn_client(txn_id)
    if client_id is None:
        raise HttpError(404, "That transaction no longer exists")
    _assert_client_visible(u, client_id)
    txn = db.query("SELECT * FROM tp_transaction WHERE id = ?", (txn_id,), one=True)
    src, dst = txn["source_country"], txn["destination_country"]
    countries = [c for c in {src, dst} if c]
    rules = {c: db.query("SELECT * FROM tp_regulatory_rule WHERE country = ? ORDER BY domain, code", (c,)) for c in countries}
    treaty = None
    if src and dst and src != dst:
        treaty = db.query(
            "SELECT * FROM tp_treaty WHERE (country_a = ? AND country_b = ?) OR (country_a = ? AND country_b = ?)",
            (src, dst, dst, src), one=True)
    return {"source_country": src, "destination_country": dst, "is_cross_border": bool(src and dst and src != dst),
            "rules_by_country": rules, "treaty": treaty}


# ---- Treaty Analysis Engine — a treaty row spans two countries, so it
# doesn't fit tp_regulatory_rule's single-country shape; read-only for
# everyone with the module, admin-only to write, same lock as Rules.

@route("GET", r"/api/tp/treaties")
def tp_list_treaties(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    return db.query("SELECT * FROM tp_treaty ORDER BY country_a, country_b")


@route("PUT", r"/api/tp/treaties/(\d+)")
def tp_update_treaty(env, m):
    u = _user(env)
    _require_admin(u)
    b = _json_body(env)
    row = db.query("SELECT * FROM tp_treaty WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That treaty entry no longer exists")
    fields = ["treaty_name", "signed_date", "effective_date", "associated_enterprise_article", "business_profits_article",
              "permanent_establishment_article", "interest_article", "royalty_article", "technical_service_article",
              "mutual_agreement_procedure_article", "source_authority", "notes"]
    sets = [f"{f} = COALESCE(?, {f})" for f in fields] + \
           (["in_force = ?"] if "in_force" in b else []) + \
           (["verification_status = ?"] if "verification_status" in b else []) + \
           ["last_verified_on = ?", "verified_by = ?"]
    args = [b.get(f) for f in fields] + \
           ([1 if b["in_force"] else 0] if "in_force" in b else []) + \
           ([b["verification_status"]] if "verification_status" in b else []) + \
           [b.get("last_verified_on") or db.now_iso()[:10], u["id"]]
    args.append(m.group(1))
    db.execute(f"UPDATE tp_treaty SET {', '.join(sets)} WHERE id = ?", args)
    db.audit(u["id"], "update", "tp_treaty", m.group(1), f"{row['country_a']}-{row['country_b']}")
    return db.query("SELECT * FROM tp_treaty WHERE id = ?", (m.group(1),), one=True)


@route("GET", r"/api/tp/rules/compare")
def tp_compare_rules(env, m):
    u = _user(env)
    _require_module(u, TP_MODULE)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    codes = (qs.get("countries") or [""])[0]
    countries = [c.strip() for c in codes.split(",") if c.strip()] or [c["code"] for c in db.query("SELECT code FROM tp_country WHERE is_active = 1")]
    rules = {c: db.query("SELECT * FROM tp_regulatory_rule WHERE country = ? ORDER BY domain, code", (c,)) for c in countries}
    return {"countries": countries, "rules_by_country": rules}


# ----------------------------------------------------------- document follow-ups

@route("GET", r"/api/clients/(\d+)/followups")
def list_followups(env, m):
    u = _user(env)
    return db.query(
        "SELECT f.*, au.full_name created_by_name FROM client_followup f"
        " JOIN client c ON c.id = f.client_id LEFT JOIN app_user au ON au.id = f.created_by"
        " WHERE f.client_id = ? AND c.firm_id = ? ORDER BY f.status, f.due_date IS NULL, f.due_date",
        (m.group(1), u["firm_id"]))


@route("POST", r"/api/clients/(\d+)/followups")
def create_followup(env, m):
    u = _user(env)
    b = _json_body(env)
    if not (b.get("item") or "").strip():
        raise HttpError(400, "A follow-up needs a description of what's being chased")
    client = db.query("SELECT id FROM client WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not client:
        raise HttpError(404, "That client no longer exists")
    fid = db.execute(
        "INSERT INTO client_followup (client_id, item, note, due_date, created_by) VALUES (?,?,?,?,?)",
        (m.group(1), b["item"].strip(), b.get("note"), b.get("due_date"), u["id"]))
    db.audit(u["id"], "create", "client_followup", fid, b["item"])
    return db.query("SELECT * FROM client_followup WHERE id = ?", (fid,), one=True)


@route("PUT", r"/api/followups/(\d+)")
def update_followup(env, m):
    u = _user(env)
    b = _json_body(env)
    row = db.query("SELECT f.id FROM client_followup f JOIN client c ON c.id = f.client_id"
                    " WHERE f.id = ? AND c.firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That follow-up no longer exists")
    fields = ["item", "status", "note", "due_date"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if not sets:
        raise HttpError(400, "Nothing to update")
    args = [b[f] for f in fields if f in b] + [u["id"], m.group(1)]
    db.execute(f"UPDATE client_followup SET {', '.join(sets)}, updated_by = ?, updated_at = datetime('now')"
               f" WHERE id = ?", args)
    db.audit(u["id"], "update", "client_followup", m.group(1))
    return db.query("SELECT * FROM client_followup WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/followups/(\d+)")
def delete_followup(env, m):
    u = _user(env)
    row = db.query("SELECT f.id FROM client_followup f JOIN client c ON c.id = f.client_id"
                    " WHERE f.id = ? AND c.firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That follow-up no longer exists")
    db.execute("DELETE FROM client_followup WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "client_followup", int(m.group(1)))
    return {"ok": True}


# -------------------------------------------------------- portal credentials
# secret_enc never leaves this file unencrypted except through the one
# explicit "reveal" endpoint, which is audit-logged so there's a record of
# who looked up which client's IRD/OCR password and when.

@route("GET", r"/api/clients/(\d+)/credentials")
def list_credentials(env, m):
    u = _user(env)
    rows = db.query(
        "SELECT cr.id, cr.client_id, cr.`system`, cr.username, cr.notes, cr.created_at, cr.updated_at,"
        " au.full_name created_by_name, (cr.secret_enc IS NOT NULL AND cr.secret_enc != '') has_secret"
        " FROM client_credential cr JOIN client c ON c.id = cr.client_id"
        " LEFT JOIN app_user au ON au.id = cr.created_by"
        " WHERE cr.client_id = ? AND c.firm_id = ? ORDER BY cr.`system`", (m.group(1), u["firm_id"]))
    return rows


@route("POST", r"/api/clients/(\d+)/credentials")
def create_credential(env, m):
    u = _user(env)
    b = _json_body(env)
    if not (b.get("system") or "").strip():
        raise HttpError(400, "A credential needs a system label (IRD, OCR, Excise, ...)")
    client = db.query("SELECT id FROM client WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not client:
        raise HttpError(404, "That client no longer exists")
    cid = db.execute(
        "INSERT INTO client_credential (client_id, `system`, username, secret_enc, notes, created_by)"
        " VALUES (?,?,?,?,?,?)",
        (m.group(1), b["system"].strip(), b.get("username"), db.encrypt_secret(b.get("password") or ""),
         b.get("notes"), u["id"]))
    db.audit(u["id"], "create", "client_credential", cid, b["system"])
    return {"id": cid}


@route("PUT", r"/api/credentials/(\d+)")
def update_credential(env, m):
    u = _user(env)
    b = _json_body(env)
    row = db.query("SELECT cr.id FROM client_credential cr JOIN client c ON c.id = cr.client_id"
                    " WHERE cr.id = ? AND c.firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That credential no longer exists")
    fields = ["system", "username", "notes"]
    sets = [(f"`{f}` = ?" if f == "system" else f"{f} = ?") for f in fields if f in b]
    args = [b[f] for f in fields if f in b]
    if "password" in b:
        sets.append("secret_enc = ?")
        args.append(db.encrypt_secret(b["password"] or ""))
    if not sets:
        raise HttpError(400, "Nothing to update")
    args += [u["id"], m.group(1)]
    db.execute(f"UPDATE client_credential SET {', '.join(sets)}, updated_by = ?, updated_at = datetime('now')"
               f" WHERE id = ?", args)
    db.audit(u["id"], "update", "client_credential", m.group(1))
    return {"ok": True}


@route("DELETE", r"/api/credentials/(\d+)")
def delete_credential(env, m):
    u = _user(env)
    row = db.query("SELECT cr.id, cr.`system` FROM client_credential cr JOIN client c ON c.id = cr.client_id"
                    " WHERE cr.id = ? AND c.firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That credential no longer exists")
    db.execute("DELETE FROM client_credential WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "client_credential", int(m.group(1)), row["system"])
    return {"ok": True}


@route("GET", r"/api/credentials/(\d+)/reveal")
def reveal_credential(env, m):
    u = _user(env)
    row = db.query("SELECT cr.*, c.name client_name FROM client_credential cr"
                    " JOIN client c ON c.id = cr.client_id WHERE cr.id = ? AND c.firm_id = ?",
                    (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That credential no longer exists")
    db.audit(u["id"], "reveal", "client_credential", m.group(1), f"{row['client_name']} — {row['system']}",
             ip=env.get("REMOTE_ADDR"))
    return {"password": db.decrypt_secret(row["secret_enc"])}


# --------------------------------------------------------------- client export

@route("POST", r"/api/clients/export")
def export_clients(env, m):
    u = _user(env)
    b = _json_body(env)
    ids = b.get("ids") or []
    if not ids:
        raise HttpError(400, "Select at least one client to export")
    if not u["is_super_admin"]:
        visible = db.visible_client_ids(u)
        ids = [i for i in ids if int(i) in visible]
        if not ids:
            raise HttpError(404, "None of the selected clients could be found")
    placeholders = ",".join("?" * len(ids))
    rows = db.query(
        f"SELECT cl.*, au.full_name assigned_to_name FROM client cl"
        f" LEFT JOIN app_user au ON au.id = cl.assigned_to"
        f" WHERE cl.firm_id = ? AND cl.id IN ({placeholders}) ORDER BY cl.name",
        [u["firm_id"]] + ids)
    if not rows:
        raise HttpError(404, "None of the selected clients could be found")
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": f"{len(rows)} clients", "fy": "", "unverified": []}
    try:
        path, filename = reports.build("client_directory", "xlsx", [_client_out(r) for r in rows], meta, REPORT_DIR)
    except Exception:
        raise HttpError(500, "The export could not be built: " + traceback.format_exc()[-1000:])
    rid = db.execute(
        "INSERT INTO report (module_key, format, filename, storage_path, params, status, generated_by, byte_size)"
        " VALUES (?,?,?,?,?,?,?,?)",
        ("client_directory", "xlsx", filename, path, json.dumps(ids), "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "client", None, f"{len(rows)} clients")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download",
            "byte_size": os.path.getsize(path)}


# -------------------------------------------------------------- ca assistant
# The chat feature described in docs/ca-agent-original/ — a Claude-backed CA
# reasoning assistant, grounded in this firm's own rate_master (never a
# second knowledge store) and the selected client's own facts. Read-only
# against rate_master for now: it can draft a proposed entry in its reply
# but does not write one itself.

@route("GET", r"/api/ca-assistant/status")
def ca_assistant_status(env, m):
    _user(env)
    return ca_assistant.status()


@route("GET", r"/api/ca-assistant/quick-questions")
def ca_assistant_quick_questions(env, m):
    _user(env)
    return ca_assistant.QUICK_QUESTIONS


@route("POST", r"/api/ca-assistant/quick")
def ca_assistant_quick(env, m):
    u = _user(env)
    b = _json_body(env)
    client_id = b.get("client_id")
    question_key = b.get("question_key")
    try:
        answer = ca_assistant.quick_answer(question_key, client_id)
    except ca_assistant.AssistantError as e:
        raise HttpError(400, str(e))
    label = next((q["label"] for q in ca_assistant.QUICK_QUESTIONS if q["key"] == question_key), question_key)
    db.execute(
        "INSERT INTO ca_assistant_message (user_id, client_id, user_message, assistant_reply, model)"
        " VALUES (?,?,?,?,?)",
        (u["id"], client_id, label, answer, "local"))
    return {"answer": answer}


@route("POST", r"/api/ca-assistant/chat")
def ca_assistant_chat(env, m):
    u = _user(env)
    b = _json_body(env)
    messages = b.get("messages") or []
    if not messages or messages[-1].get("role") != "user":
        raise HttpError(400, "Send at least one message, ending with the user's turn")
    client_id = b.get("client_id")
    engagement_id = b.get("engagement_id")
    try:
        result = ca_assistant.chat(messages, client_id=client_id, engagement_id=engagement_id, fy=b.get("fy"))
    except ca_assistant.AssistantError as e:
        raise HttpError(503, str(e))
    db.execute(
        "INSERT INTO ca_assistant_message (user_id, client_id, engagement_id, user_message, assistant_reply,"
        " knowledge_context, client_context, model, input_tokens, output_tokens) VALUES (?,?,?,?,?,?,?,?,?,?)",
        (u["id"], client_id, engagement_id, messages[-1]["content"], result["text"],
         result["knowledge_context"], result["client_context"], result["model"],
         result["usage"]["input_tokens"], result["usage"]["output_tokens"]))
    db.audit(u["id"], "chat", "ca_assistant", None,
             f"{result['usage']['input_tokens']}in/{result['usage']['output_tokens']}out")
    return {"reply": result["text"], "knowledge_context_used": bool(result["knowledge_context"]),
            "usage": result["usage"]}


@route("GET", r"/api/ca-assistant/history")
def ca_assistant_history(env, m):
    u = _user(env)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    sql = "SELECT * FROM ca_assistant_message WHERE user_id = ?"
    args = [u["id"]]
    if qs.get("client_id"):
        sql += " AND client_id = ?"; args.append(qs["client_id"][0])
    sql += " ORDER BY created_at DESC LIMIT 100"
    return db.query(sql, args)


# ------------------------------------------------------------------ calendar
# AD is the source of truth everywhere in this app (fiscal_year.start_ad etc);
# these two endpoints only add a BS cross-reference for a date picker. Public
# — pure date arithmetic, no client data, same tradeoff as the import template.

@route("GET", r"/api/calendar/bs-from-ad")
def bs_from_ad(env, m):
    qs = parse_qs(env.get("QUERY_STRING", ""))
    ad = (qs.get("date") or [""])[0]
    if not ad:
        raise HttpError(400, "date is required, e.g. ?date=2025-07-17")
    try:
        bs = nepal_calendar.bs_approx(ad)
    except ValueError:
        raise HttpError(400, "That doesn't look like a valid date (YYYY-MM-DD)")
    fys = db.query("SELECT label_bs, start_ad, end_ad FROM fiscal_year")
    return {"bs": bs, "fy_label": nepal_calendar.fy_label_for_ad(ad, fys)}


@route("GET", r"/api/calendar/ad-from-bs")
def ad_from_bs(env, m):
    qs = parse_qs(env.get("QUERY_STRING", ""))
    try:
        year = int((qs.get("year") or [""])[0])
        month = int((qs.get("month") or [""])[0])
        day = int((qs.get("day") or [""])[0])
    except ValueError:
        raise HttpError(400, "year, month and day are all required, e.g. ?year=2082&month=4&day=1")
    if not (1 <= month <= 12 and 1 <= day <= 32):
        raise HttpError(400, "month must be 1-12 and day 1-32")
    try:
        ad = nepal_calendar.bs_to_ad_approx(year, month, day)
    except ValueError:
        raise HttpError(400, "That BS date doesn't exist — check the day against that month's real length")
    fys = db.query("SELECT label_bs, start_ad, end_ad FROM fiscal_year")
    return {"ad": ad, "fy_label": nepal_calendar.fy_label_for_ad(ad, fys)}


# --------------------------------------------------------------- bulk import
# Template download carries no client data (blank headers and worked samples
# only), so — like static assets — it needs no session, the same tradeoff the
# existing report-download link already makes for a plain <a> click to work.

@route("GET", r"/api/import/(\w+)/template")
def import_template(env, m):
    module_key = m.group(1)
    if module_key not in bulk_import.IMPORT_SPECS:
        raise HttpError(404, "No import template for this module")
    blob = bulk_import.build_template(module_key)
    return ("__raw__", blob, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            f"{module_key}-import-template.xlsx")


@route("POST", r"/api/import/(\w+)")
def import_upload(env, m):
    _user(env)
    module_key = m.group(1)
    if module_key not in bulk_import.IMPORT_SPECS:
        raise HttpError(404, "No import format for this module")
    b = _json_body(env)
    content = b.get("content_base64")
    if not content:
        raise HttpError(400, "No file content received")
    try:
        blob = base64.b64decode(content)
    except Exception:
        raise HttpError(400, "The uploaded file could not be read")
    try:
        return bulk_import.parse_upload(module_key, blob)
    except ValueError as e:
        raise HttpError(400, str(e))


# ------------------------------------------------------------------- leads
# Public — no session required. The app is single-firm-per-install, so this
# isn't self-service signup; it just reaches the people who provision desks.

@route("POST", r"/api/leads")
def create_lead(env, m):
    # Public route — this is a "Request access" signup on the shared
    # landing page, so it belongs to that site's own firm, same as every
    # other public-site route (see the "PUBLIC — main landing page" note on
    # public_site above). Every tenant firm's staff used to see (and could
    # even convert into their own client!) every other firm's leads because
    # this table had no firm_id at all — see _backfill_lead_firm_id.
    b = _json_body(env)
    name = (b.get("name") or "").strip()
    email = (b.get("email") or "").strip()
    if not name or not email:
        raise HttpError(400, "Name and email are required")
    db.execute(
        "INSERT INTO `lead` (name, email, phone, message, utm_source, status, firm_id) VALUES (?,?,?,?,?,?,?)",
        (name, email, (b.get("phone") or "").strip(), (b.get("message") or "").strip(),
         b.get("source") or "landing_signup", "new", _default_firm_id()))
    return {"ok": True}


_LEAD_STATUSES = ("new", "contacted", "qualified", "proposal", "converted", "lost", "archived")


@route("GET", r"/api/leads")
def list_leads(env, m):
    u = _user(env)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    sql = ("SELECT l.*, au.full_name assigned_to_name FROM `lead` l LEFT JOIN app_user au ON au.id = l.assigned_to"
          " WHERE l.firm_id = ?")
    args = [u["firm_id"]]
    if qs.get("status"):
        sql += " AND l.status = ?"; args.append(qs["status"][0])
    if qs.get("assigned_to"):
        sql += " AND l.assigned_to = ?"; args.append(qs["assigned_to"][0])
    return db.query(sql + " ORDER BY l.created_at DESC", args)


@route("PUT", r"/api/leads/(\d+)")
def update_lead(env, m):
    u = _user(env)
    row = db.query("SELECT id FROM `lead` WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That request no longer exists")
    b = _json_body(env)
    sets, args = [], []
    if "status" in b:
        if b["status"] not in _LEAD_STATUSES:
            raise HttpError(400, "Unknown status")
        sets.append("status = ?"); args.append(b["status"])
    if "assigned_to" in b:
        sets.append("assigned_to = ?"); args.append(b["assigned_to"] or None)
    if "follow_up_date" in b:
        sets.append("follow_up_date = ?"); args.append(b["follow_up_date"] or None)
    if "notes" in b:
        sets.append("notes = ?"); args.append(b["notes"])
    if not sets:
        raise HttpError(400, "Nothing to update")
    db.execute(f"UPDATE `lead` SET {', '.join(sets)} WHERE id = ?", args + [m.group(1)])
    db.audit(u["id"], "update", "lead", m.group(1))
    return db.query("SELECT l.*, au.full_name assigned_to_name FROM `lead` l LEFT JOIN app_user au ON au.id = l.assigned_to"
                    " WHERE l.id = ?", (m.group(1),), one=True)


@route("POST", r"/api/leads/(\d+)/convert")
def convert_lead(env, m):
    """Creates a real client record from a lead's captured details and marks
    the lead converted — the one CRM action every other status change is
    ultimately building toward. A lead already converted can't be converted
    again (its client_id is already set), but re-running this on the same
    lead is otherwise safe: the client code is generated fresh each time
    from the firm's existing next-available-code logic, same as the regular
    "add client" flow."""
    u = _user(env)
    _require_admin(u)
    lead = db.query("SELECT * FROM `lead` WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not lead:
        raise HttpError(404, "That request no longer exists")
    if lead["client_id"]:
        raise HttpError(400, "This request has already been converted to a client")
    b = _json_body(env)
    name = (b.get("name") or lead["name"] or "").strip()
    if not name:
        raise HttpError(400, "A client name is required")
    code = b.get("code") or _next_client_code(u["firm_id"])
    cid = db.execute(
        "INSERT INTO client (firm_id, code, name, contact_person, contact_phone, contact_email, notes, assigned_to)"
        " VALUES (?,?,?,?,?,?,?,?)",
        (u["firm_id"], code, name, lead["name"], lead["phone"], lead["email"],
         "Converted from website lead #" + str(lead["id"]), u["id"]))
    db.execute("UPDATE `lead` SET status = 'converted', client_id = ? WHERE id = ?", (cid, m.group(1)))
    db.audit(u["id"], "convert", "lead", m.group(1), f"-> client #{cid}")
    return db.query("SELECT * FROM client WHERE id = ?", (cid,), one=True)


# --------------------------------------------------------- public website
# Content for the public marketing site (services, static pages,
# testimonials, firm/contact/Maps info) — admin-managed, publicly readable,
# never requiring a session. Single-firm-per-install, same as the rest of
# this app, so these read whichever firm row exists rather than taking a
# firm_id from the caller.

def _slugify(text):
    """Lowercase, hyphenated, URL-safe. Every admin-authored slug (service,
    page, blog post/category, job posting) is normalized through this before
    it reaches the database — the public routes for these all match a slug
    against `[a-z0-9-]+`, so an unnormalized value like "CA Firm" or "My
    Page!" doesn't just look untidy, it makes that URL permanently
    unreachable (the exact "No route for ..." failure this was added to
    prevent)."""
    s = re.sub(r"[^a-z0-9]+", "-", (text or "").strip().lower()).strip("-")
    return s or secrets.token_hex(4)


_YOUTUBE_ID_RE = re.compile(
    r"(?:youtube\.com/(?:watch\?(?:.*&)?v=|embed/|shorts/|live/)|youtu\.be/)([A-Za-z0-9_-]{11})")


def _extract_youtube_id(url):
    """Accepts whatever an admin actually pastes — a watch link, a share
    link (youtu.be), an embed link, a Shorts link, with or without extra
    query params — or a bare 11-character id, and returns just the id.
    Returns None for anything that doesn't look like a real YouTube video,
    so the caller can reject it with a clear error instead of silently
    saving a broken embed."""
    if not url:
        return None
    url = url.strip()
    m = _YOUTUBE_ID_RE.search(url)
    if m:
        return m.group(1)
    if re.fullmatch(r"[A-Za-z0-9_-]{11}", url):
        return url
    return None


def _execute_or_slug_conflict(sql, args):
    """Every admin-managed content table below (service/page/blog_post/
    job_posting/*_category) has a UNIQUE(slug) or UNIQUE(firm_id, slug)
    constraint — this turns the resulting db.IntegrityError into a
    plain 400 instead of the generic 500 a raw exception would otherwise
    produce, since a duplicate slug is a validation failure the preparer can
    fix immediately, not a server fault."""
    try:
        return db.execute(sql, args)
    except db.IntegrityError:
        raise HttpError(400, "That slug is already in use — choose another")


def _default_firm_id():
    row = db.query("SELECT id FROM firm ORDER BY id LIMIT 1", one=True)
    if not row:
        raise HttpError(500, "No firm is configured yet")
    return row["id"]


def _firm_name():
    row = db.query("SELECT name FROM firm ORDER BY id LIMIT 1", one=True)
    return row["name"] if row else "Our team"


_FIRM_PUBLIC_FIELDS = (
    "id", "name", "short_name", "tagline", "address", "phone", "email", "website",
    "logo_path", "base_currency", "google_maps_url", "google_maps_embed", "office_hours",
    "facebook_url", "linkedin_url", "twitter_url", "instagram_url",
)


# PUBLIC — main landing page. There is exactly one shared public marketing
# site (services, testimonials, about, pages, banner, YouTube reel), and it
# belongs to CA-Automator's own firm (_default_firm_id(), the platform's
# first-ever firm — same id nav_menu_item's query below already hardcodes).
# A tenant firm granted "site_settings" can freely edit its OWN service/
# testimonial/etc rows (every admin route above scopes writes to
# u["firm_id"]) — those edits just never appear here, because this is
# CA-Automator's site, not theirs. Without this filter every one of these
# routes would silently publish a tenant firm's content onto CA-Automator's
# own public site, indistinguishable from its official content — that's the
# leak this comment block exists to prevent from creeping back in. Careers
# is the deliberate, documented exception (see public_list_careers) — every
# firm's vacancies DO share this landing page, by the firm's own request,
# each one clearly attributed to its actual hiring firm.
@route("GET", r"/api/public/site")
def public_site(env, m):
    row = db.query("SELECT * FROM firm WHERE id = ?", (_default_firm_id(),), one=True)
    firm = dict(row) if row else {}
    return {
        "firm": {k: firm.get(k) for k in _FIRM_PUBLIC_FIELDS},
        "services": db.query(
            "SELECT s.id, s.slug, s.name, s.summary, s.price_type, s.price, s.currency, s.image_path, s.is_buyable,"
            " sc.name category_name, sc.slug category_slug FROM service s"
            " LEFT JOIN service_category sc ON sc.id = s.category_id"
            " WHERE s.is_published = 1 AND s.firm_id = ? ORDER BY sc.sort_order, s.sort_order, s.name",
            (_default_firm_id(),)),
        "testimonials": db.query(
            "SELECT id, client_name, client_title, company, content, rating FROM testimonial"
            " WHERE is_published = 1 AND firm_id = ? ORDER BY sort_order, id DESC", (_default_firm_id(),)),
        # "Our Clients" showcase — every OTHER tenant firm a platform admin
        # has explicitly opted in (show_on_landing=1); a firm never
        # showcases itself. Logo-less firms are skipped rather than shown
        # with a blank tile — nothing to roll without one.
        "showcased_firms": db.query(
            "SELECT id, name, logo_path FROM firm WHERE show_on_landing = 1 AND logo_path IS NOT NULL"
            " AND logo_path != '' AND id != ? ORDER BY name", (_default_firm_id(),)),
    }


@route("GET", r"/api/public/services")
def public_list_services(env, m):
    return db.query(
        "SELECT s.*, sc.name category_name, sc.slug category_slug FROM service s"
        " LEFT JOIN service_category sc ON sc.id = s.category_id"
        " WHERE s.is_published = 1 AND s.firm_id = ? ORDER BY sc.sort_order, s.sort_order, s.name",
        (_default_firm_id(),))


@route("GET", r"/api/public/services/([a-z0-9-]+)")
def public_get_service(env, m):
    row = db.query(
        "SELECT s.*, sc.name category_name, sc.slug category_slug FROM service s"
        " LEFT JOIN service_category sc ON sc.id = s.category_id"
        " WHERE s.slug = ? AND s.is_published = 1 AND s.firm_id = ?", (m.group(1), _default_firm_id()), one=True)
    if not row:
        raise HttpError(404, "That service page doesn't exist")
    return row


@route("GET", r"/api/public/pages")
def public_list_pages(env, m):
    """Backs the footer's dynamic "legal/company pages" links — whatever
    static pages exist (About, Privacy Policy, Terms, ...) show up here with
    no hardcoded slug list on the frontend, so a page created in Admin
    appears in the footer on its own."""
    return db.query("SELECT slug, title FROM page WHERE is_published = 1 AND firm_id = ? ORDER BY title",
                    (_default_firm_id(),))


@route("GET", r"/api/public/menu")
def public_menu(env, m):
    """The site's top nav and footer link column — admin-managed
    (Admin > Site Settings > Menu) instead of hardcoded in index.html.
    Only visible items, already sorted, grouped by section so the
    frontend can drop each list straight into its slot with no
    client-side filtering."""
    rows = db.query("SELECT section, label, url FROM nav_menu_item"
                    " WHERE firm_id = 1 AND is_visible = 1 ORDER BY section, sort_order")
    out = {"primary": [], "footer": []}
    for r in rows:
        if r["section"] in out:
            out[r["section"]].append({"label": r["label"], "url": r["url"]})
    return out


@route("GET", r"/api/public/pages/([a-z0-9-]+)")
def public_get_page(env, m):
    row = db.query("SELECT * FROM page WHERE slug = ? AND is_published = 1 AND firm_id = ?",
                   (m.group(1), _default_firm_id()), one=True)
    if not row:
        raise HttpError(404, "That page doesn't exist")
    return row


@route("GET", r"/api/public/testimonials")
def public_list_testimonials(env, m):
    return db.query("SELECT id, client_name, client_title, company, content, rating FROM testimonial"
                    " WHERE is_published = 1 AND firm_id = ? ORDER BY sort_order, id DESC", (_default_firm_id(),))


@route("GET", r"/api/public/about")
def public_about(env, m):
    fid = _default_firm_id()
    return {
        "milestones": db.query("SELECT id, year, title, description, image_path FROM about_milestone"
                               " WHERE is_published = 1 AND firm_id = ? ORDER BY sort_order, year", (fid,)),
        "team": db.query("SELECT id, slug, name, designation, bio, expertise, photo_path, linkedin_url FROM team_member"
                         " WHERE is_published = 1 AND firm_id = ? ORDER BY sort_order, id", (fid,)),
    }


@route("GET", r"/api/admin/about-milestones")
def admin_list_milestones(env, m):
    u = _user(env)
    return db.query("SELECT * FROM about_milestone WHERE firm_id = ? ORDER BY sort_order, year", (u["firm_id"],))


@route("POST", r"/api/admin/about-milestones")
def admin_create_milestone(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    if not b.get("year") or not b.get("title"):
        raise HttpError(400, "Year and title are required")
    mid = db.execute(
        "INSERT INTO about_milestone (firm_id, year, title, description, image_path, sort_order, is_published)"
        " VALUES (?,?,?,?,?,?,?)",
        (u["firm_id"], b["year"], b["title"], b.get("description"), b.get("image_path"),
         int(b.get("sort_order") or 0), 1 if b.get("is_published") else 0))
    db.audit(u["id"], "create", "about_milestone", mid, b["title"])
    return db.query("SELECT * FROM about_milestone WHERE id = ?", (mid,), one=True)


@route("PUT", r"/api/admin/about-milestones/(\d+)")
def admin_update_milestone(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT id FROM about_milestone WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That milestone no longer exists")
    b = _json_body(env)
    db.execute(
        "UPDATE about_milestone SET year=?, title=?, description=?, image_path=?, sort_order=?, is_published=? WHERE id=?",
        (b.get("year"), b.get("title"), b.get("description"), b.get("image_path"),
         int(b.get("sort_order") or 0), 1 if b.get("is_published") else 0, m.group(1)))
    db.audit(u["id"], "update", "about_milestone", m.group(1))
    return db.query("SELECT * FROM about_milestone WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/about-milestones/(\d+)")
def admin_delete_milestone(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    db.execute("DELETE FROM about_milestone WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]))
    db.audit(u["id"], "delete", "about_milestone", m.group(1))
    return {"ok": True}


@route("GET", r"/api/admin/team-members")
def admin_list_team(env, m):
    u = _user(env)
    return db.query("SELECT * FROM team_member WHERE firm_id = ? ORDER BY sort_order, id", (u["firm_id"],))


@route("POST", r"/api/admin/team-members")
def admin_create_team_member(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    if not b.get("name"):
        raise HttpError(400, "A name is required")
    tid = _execute_or_slug_conflict(
        "INSERT INTO team_member (firm_id, slug, name, designation, bio, expertise, photo_path, linkedin_url,"
        " sort_order, is_published) VALUES (?,?,?,?,?,?,?,?,?,?)",
        (u["firm_id"], _slugify(b.get("slug") or b["name"]), b["name"], b.get("designation"), b.get("bio"),
         b.get("expertise"), b.get("photo_path"), b.get("linkedin_url"), int(b.get("sort_order") or 0),
         1 if b.get("is_published") else 0))
    db.audit(u["id"], "create", "team_member", tid, b["name"])
    return db.query("SELECT * FROM team_member WHERE id = ?", (tid,), one=True)


@route("PUT", r"/api/admin/team-members/(\d+)")
def admin_update_team_member(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT * FROM team_member WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That team member no longer exists")
    b = _json_body(env)
    # Partial update — team members are actually always saved via the full
    # admin form (every field present), but this stays defensive rather
    # than repeating the blind-overwrite shape that silently corrupted
    # testimonials/downloads elsewhere in this file (see admin_update_
    # testimonial/admin_update_download for the bug this pattern avoids).
    name = b.get("name") if "name" in b else row["name"]
    slug = _slugify(b.get("slug") or name) if ("slug" in b or "name" in b) else row["slug"]
    fields = {"name": name, "slug": slug}
    for f in ("designation", "bio", "expertise", "photo_path", "linkedin_url"):
        fields[f] = b[f] if f in b else row[f]
    fields["sort_order"] = int(b["sort_order"]) if "sort_order" in b else row["sort_order"]
    fields["is_published"] = (1 if b.get("is_published") else 0) if "is_published" in b else row["is_published"]
    _execute_or_slug_conflict(
        "UPDATE team_member SET name=?, slug=?, designation=?, bio=?, expertise=?, photo_path=?, linkedin_url=?,"
        " sort_order=?, is_published=? WHERE id=?",
        (fields["name"], fields["slug"], fields["designation"], fields["bio"], fields["expertise"],
         fields["photo_path"], fields["linkedin_url"], fields["sort_order"], fields["is_published"], m.group(1)))
    db.audit(u["id"], "update", "team_member", m.group(1))
    return db.query("SELECT * FROM team_member WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/team-members/(\d+)")
def admin_delete_team_member(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    db.execute("DELETE FROM team_member WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]))
    db.audit(u["id"], "delete", "team_member", m.group(1))
    return {"ok": True}


@route("GET", r"/api/public/youtube-videos")
def public_youtube_videos(env, m):
    return db.query("SELECT id, title, video_id FROM youtube_video WHERE is_published = 1 AND firm_id = ?"
                    " ORDER BY sort_order, id", (_default_firm_id(),))


@route("GET", r"/api/admin/youtube-videos")
def admin_list_youtube_videos(env, m):
    u = _user(env)
    return db.query("SELECT * FROM youtube_video WHERE firm_id = ? ORDER BY sort_order, id", (u["firm_id"],))


@route("POST", r"/api/admin/youtube-videos")
def admin_create_youtube_video(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    title = (b.get("title") or "").strip()
    if not title:
        raise HttpError(400, "A title is required")
    video_id = _extract_youtube_id(b.get("youtube_url") or "")
    if not video_id:
        raise HttpError(400, "That doesn't look like a valid YouTube link — paste the video's watch, share or embed URL")
    vid = db.execute(
        "INSERT INTO youtube_video (firm_id, title, video_id, sort_order, is_published) VALUES (?,?,?,?,?)",
        (u["firm_id"], title, video_id, int(b.get("sort_order") or 0), 1 if b.get("is_published") else 0))
    db.audit(u["id"], "create", "youtube_video", vid, title)
    return db.query("SELECT * FROM youtube_video WHERE id = ?", (vid,), one=True)


@route("PUT", r"/api/admin/youtube-videos/(\d+)")
def admin_update_youtube_video(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT id FROM youtube_video WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That video no longer exists")
    b = _json_body(env)
    sets, args = [], []
    if "title" in b:
        title = (b.get("title") or "").strip()
        if not title:
            raise HttpError(400, "A title is required")
        sets.append("title = ?"); args.append(title)
    if "youtube_url" in b:
        video_id = _extract_youtube_id(b.get("youtube_url") or "")
        if not video_id:
            raise HttpError(400, "That doesn't look like a valid YouTube link — paste the video's watch, share or embed URL")
        sets.append("video_id = ?"); args.append(video_id)
    if "sort_order" in b:
        sets.append("sort_order = ?"); args.append(int(b.get("sort_order") or 0))
    if "is_published" in b:
        # This is the deactivate/reactivate toggle — the row (and its
        # position) is kept, it just stops showing on the public site.
        sets.append("is_published = ?"); args.append(1 if b["is_published"] else 0)
    if not sets:
        raise HttpError(400, "Nothing to update")
    db.execute(f"UPDATE youtube_video SET {', '.join(sets)} WHERE id = ?", args + [m.group(1)])
    db.audit(u["id"], "update", "youtube_video", m.group(1))
    return db.query("SELECT * FROM youtube_video WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/youtube-videos/(\d+)")
def admin_delete_youtube_video(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    db.execute("DELETE FROM youtube_video WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]))
    db.audit(u["id"], "delete", "youtube_video", m.group(1))
    return {"ok": True}


@route("POST", r"/api/contact")
def public_contact(env, m):
    b = _json_body(env)
    name = (b.get("name") or "").strip()
    email = (b.get("email") or "").strip()
    message = (b.get("message") or "").strip()
    if not name or not email or not message:
        raise HttpError(400, "Name, email and message are required")
    service_id = b.get("service_id") or None
    if service_id and not db.query("SELECT 1 FROM service WHERE id = ?", (service_id,), one=True):
        service_id = None
    lid = db.execute(
        "INSERT INTO `lead` (name, email, phone, message, subject, service_id, utm_source, status, firm_id)"
        " VALUES (?,?,?,?,?,?,?, 'new', ?)",
        (name, email, (b.get("phone") or "").strip(), message, (b.get("subject") or "").strip(),
         service_id, "contact_form", _default_firm_id()))
    firm_row = db.query("SELECT name, email FROM firm ORDER BY id LIMIT 1", one=True)
    firm = dict(firm_row) if firm_row else {}
    notify.send_email(
        email, "We've received your message",
        f"Hi {name},\n\nThanks for reaching out — we've received your message and will get back to you shortly.\n\n"
        f"Your message:\n{message}\n\n— {firm.get('name', 'Our team')}",
        related_type="lead", related_id=lid)
    if firm.get("email"):
        notify.send_email(
            firm["email"], f"New website enquiry from {name}",
            f"Name: {name}\nEmail: {email}\nPhone: {(b.get('phone') or '').strip()}\n"
            f"Subject: {(b.get('subject') or '').strip()}\n\n{message}",
            related_type="lead", related_id=lid)
    for staff in db.query("SELECT id FROM app_user WHERE is_super_admin = 1 AND is_active = 1"):
        db.execute("INSERT INTO notification (user_id, kind, title, body, link) VALUES (?,?,?,?,?)",
                   (staff["id"], "contact_message", f"New enquiry from {name}", message[:200], "/#/leads"))
    return {"ok": True}


# --------------------------------------------------------- public site chatbot
# Anonymous, unauthenticated — the landing-page widget. See site_chat.py for
# the guardrails (never confident tax/legal/accounting advice, public site
# content only) and the provider-agnostic design. session_token is generated
# client-side and kept in localStorage; it identifies a browser's chat
# session, never a real identity, until/unless the visitor submits the lead
# form.

@route("GET", r"/api/public/chat/status")
def public_chat_status(env, m):
    return site_chat.status()


@route("POST", r"/api/public/chat/message")
def public_chat_message(env, m):
    b = _json_body(env)
    session_token = (b.get("session_token") or "").strip()
    if not session_token or len(session_token) > 64:
        raise HttpError(400, "A valid session_token is required")
    try:
        result = site_chat.reply(1, session_token, b.get("message"), source=b.get("source") or "landing_page")
    except site_chat.ChatError as e:
        if str(e) == "chat_not_configured":
            raise HttpError(503, "Chat isn't available right now — please use the contact form instead.")
        raise HttpError(400, str(e))
    return result


@route("POST", r"/api/public/chat/lead")
def public_chat_lead(env, m):
    b = _json_body(env)
    session_token = (b.get("session_token") or "").strip()
    if not session_token or len(session_token) > 64:
        raise HttpError(400, "A valid session_token is required")
    try:
        conv_id = site_chat.capture_lead(
            1, session_token, b.get("name"), b.get("email"), b.get("phone"), b.get("company"),
            source=b.get("source") or "landing_page")
    except site_chat.ChatError as e:
        raise HttpError(400, str(e))
    conv = db.query("SELECT name, topic FROM chatbot_conversation WHERE id = ?", (conv_id,), one=True)
    for staff in db.query("SELECT id FROM app_user WHERE is_super_admin = 1 AND is_active = 1"):
        db.execute("INSERT INTO notification (user_id, kind, title, body, link) VALUES (?,?,?,?,?)",
                   (staff["id"], "chatbot_lead", f"New chatbot lead: {conv['name']}",
                    conv["topic"] or "", "/#/chatbot"))
    return {"ok": True}


# ------------------------------------------------------ admin: chatbot leads

@route("GET", r"/api/admin/chatbot/conversations")
def admin_list_chatbot_conversations(env, m):
    u = _user(env)
    _require_module(u, "chatbot_admin")
    qs = parse_qs(env.get("QUERY_STRING", ""))
    sql = "SELECT c.*, (SELECT COUNT(*) FROM chatbot_message WHERE conversation_id = c.id) message_count," \
          " u.full_name assigned_name FROM chatbot_conversation c" \
          " LEFT JOIN app_user u ON u.id = c.assigned_to WHERE c.firm_id = ?"
    args = [u["firm_id"]]
    if qs.get("status"):
        sql += " AND c.status = ?"; args.append(qs["status"][0])
    if qs.get("assigned_to"):
        sql += " AND c.assigned_to = ?"; args.append(qs["assigned_to"][0])
    if qs.get("source"):
        sql += " AND c.source = ?"; args.append(qs["source"][0])
    if qs.get("has_lead") == ["1"]:
        sql += " AND c.email IS NOT NULL"
    sql += " ORDER BY c.updated_at DESC LIMIT 200"
    return db.query(sql, args)


@route("GET", r"/api/admin/chatbot/conversations/(\d+)")
def admin_get_chatbot_conversation(env, m):
    u = _user(env)
    _require_module(u, "chatbot_admin")
    conv = db.query("SELECT * FROM chatbot_conversation WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not conv:
        raise HttpError(404, "That conversation no longer exists")
    conv = dict(conv)
    conv["messages"] = db.query("SELECT role, content, created_at FROM chatbot_message"
                                " WHERE conversation_id = ? ORDER BY id", (m.group(1),))
    return conv


@route("PUT", r"/api/admin/chatbot/conversations/(\d+)")
def admin_update_chatbot_conversation(env, m):
    u = _user(env)
    _require_module(u, "chatbot_admin")
    row = db.query("SELECT id FROM chatbot_conversation WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That conversation no longer exists")
    b = _json_body(env)
    sets, args = [], []
    for field in ("status", "follow_up_status", "assigned_to", "internal_note"):
        if field in b:
            sets.append(f"{field} = ?"); args.append(b[field])
    if not sets:
        raise HttpError(400, "Nothing to update")
    sets.append("updated_at = datetime('now')")
    db.execute(f"UPDATE chatbot_conversation SET {', '.join(sets)} WHERE id = ?", args + [m.group(1)])
    db.audit(u["id"], "update", "chatbot_conversation", m.group(1))
    return db.query("SELECT * FROM chatbot_conversation WHERE id = ?", (m.group(1),), one=True)


@route("POST", r"/api/admin/chatbot/conversations/(\d+)/convert")
def admin_convert_chatbot_lead(env, m):
    """Turns a captured lead into a real record elsewhere in the app. Reuses
    the existing `lead` table for enquiry/service_request (it already is
    this app's CRM enquiry model — see public_contact above) rather than
    inventing a second one. 'appointment' is intentionally not
    auto-created: booking requires picking an actual open slot
    (appointment_booking.slot_id), so this just hands the admin the
    visitor's details to complete via the Appointments screen."""
    u = _user(env)
    _require_module(u, "chatbot_admin")
    conv = db.query("SELECT * FROM chatbot_conversation WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not conv:
        raise HttpError(404, "That conversation no longer exists")
    if not conv["email"] or not conv["name"]:
        raise HttpError(400, "This conversation has no captured lead (name/email) to convert yet")
    b = _json_body(env)
    target = b.get("target")
    if target not in ("client", "enquiry", "service_request", "appointment"):
        raise HttpError(400, "target must be one of: client, enquiry, service_request, appointment")

    transcript = "\n".join(
        f"{r['role']}: {r['content']}" for r in
        db.query("SELECT role, content FROM chatbot_message WHERE conversation_id = ? ORDER BY id", (conv["id"],)))

    if target == "client":
        code = _next_client_code(u["firm_id"])
        new_id = db.execute(
            "INSERT INTO client (firm_id, code, name, contact_person, contact_email, contact_phone, notes,"
            " stage, assigned_to) VALUES (?,?,?,?,?,?,?,?,?)",
            (u["firm_id"], code, conv["company"] or conv["name"], conv["name"], conv["email"], conv["phone"],
             f"Converted from chatbot lead (conversation #{conv['id']}). Topic: {conv['topic'] or 'n/a'}",
             "active", conv["assigned_to"] or u["id"]))
    elif target in ("enquiry", "service_request"):
        new_id = db.execute(
            "INSERT INTO `lead` (name, email, phone, message, subject, utm_source, status, assigned_to, firm_id)"
            " VALUES (?,?,?,?,?,?,?,?,?)",
            (conv["name"], conv["email"], conv["phone"], transcript[:4000],
             conv["topic"] or "From website chatbot", "chatbot", "new", conv["assigned_to"], u["firm_id"]))
    else:  # appointment — no slot-less booking path exists; hand off, don't fake-create
        new_id = None

    db.execute("UPDATE chatbot_conversation SET status = 'converted', converted_to = ?, converted_id = ?,"
              " updated_at = datetime('now') WHERE id = ?", (target, new_id, conv["id"]))
    db.audit(u["id"], "convert", "chatbot_conversation", m.group(1), target)
    return {"ok": True, "target": target, "id": new_id,
            "note": None if new_id else "No slot-less booking path exists — use the Appointments screen with this visitor's contact details."}


# ----------------------------------------------------- admin: website content
# Services, service categories, static pages and testimonials shown on the
# public site — CRUD, super-admin only, same gate as every other admin
# screen in this app.

@route("GET", r"/api/admin/service-categories")
def admin_list_service_categories(env, m):
    u = _user(env)
    return db.query("SELECT * FROM service_category WHERE firm_id = ? ORDER BY sort_order, name", (u["firm_id"],))


@route("POST", r"/api/admin/service-categories")
def admin_create_service_category(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    name = (b.get("name") or "").strip()
    slug = _slugify(b.get("slug") or name)
    if not name:
        raise HttpError(400, "A name is required")
    cid = _execute_or_slug_conflict("INSERT INTO service_category (firm_id, slug, name, sort_order) VALUES (?,?,?,?)",
                     (u["firm_id"], slug, name, int(b.get("sort_order") or 0)))
    db.audit(u["id"], "create", "service_category", cid, name)
    return db.query("SELECT * FROM service_category WHERE id = ?", (cid,), one=True)


@route("PUT", r"/api/admin/service-categories/(\d+)")
def admin_update_service_category(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    _execute_or_slug_conflict("UPDATE service_category SET name = ?, slug = ?, sort_order = ? WHERE id = ? AND firm_id = ?",
               (b.get("name"), _slugify(b.get("slug") or b.get("name")), int(b.get("sort_order") or 0), m.group(1), u["firm_id"]))
    db.audit(u["id"], "update", "service_category", m.group(1))
    return db.query("SELECT * FROM service_category WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/service-categories/(\d+)")
def admin_delete_service_category(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    db.execute("DELETE FROM service_category WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]))
    db.audit(u["id"], "delete", "service_category", m.group(1))
    return {"ok": True}


@route("GET", r"/api/admin/services")
def admin_list_services(env, m):
    u = _user(env)
    return db.query(
        "SELECT s.*, sc.name category_name FROM service s LEFT JOIN service_category sc ON sc.id = s.category_id"
        " WHERE s.firm_id = ? ORDER BY s.sort_order, s.name", (u["firm_id"],))


@route("GET", r"/api/admin/services/(\d+)")
def admin_get_service(env, m):
    u = _user(env)
    row = db.query("SELECT * FROM service WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That service no longer exists")
    d = dict(row)
    d["faqs"] = db.query("SELECT * FROM service_faq WHERE service_id = ? ORDER BY sort_order", (m.group(1),))
    return d


@route("POST", r"/api/admin/services")
def admin_create_service(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    name = (b.get("name") or "").strip()
    slug = _slugify(b.get("slug") or name)
    if not name:
        raise HttpError(400, "A name is required")
    sid = _execute_or_slug_conflict(
        "INSERT INTO service (firm_id, category_id, slug, name, summary, description_html, price_type, price,"
        " currency, duration_label, is_buyable, requires_appointment, image_path, meta_title, meta_description,"
        " is_published, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (u["firm_id"], b.get("category_id") or None, slug, name, b.get("summary"), b.get("description_html"),
         b.get("price_type") or "enquire", b.get("price"), b.get("currency") or "NPR", b.get("duration_label"),
         1 if b.get("is_buyable") else 0, 1 if b.get("requires_appointment", True) else 0, b.get("image_path"),
         b.get("meta_title"), b.get("meta_description"), 1 if b.get("is_published") else 0,
         int(b.get("sort_order") or 0)))
    db.audit(u["id"], "create", "service", sid, name)
    return db.query("SELECT * FROM service WHERE id = ?", (sid,), one=True)


@route("PUT", r"/api/admin/services/(\d+)")
def admin_update_service(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT id FROM service WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That service no longer exists")
    b = _json_body(env)
    _execute_or_slug_conflict(
        "UPDATE service SET category_id=?, slug=?, name=?, summary=?, description_html=?, price_type=?, price=?,"
        " currency=?, duration_label=?, is_buyable=?, requires_appointment=?, image_path=?, meta_title=?,"
        " meta_description=?, is_published=?, sort_order=?, problem_html=?, process_html=?, who_for_html=?,"
        " benefits_html=?, documents_html=?, updated_at=datetime('now') WHERE id=?",
        (b.get("category_id") or None, _slugify(b.get("slug") or b.get("name")), b.get("name"), b.get("summary"), b.get("description_html"),
         b.get("price_type") or "enquire", b.get("price"), b.get("currency") or "NPR", b.get("duration_label"),
         1 if b.get("is_buyable") else 0, 1 if b.get("requires_appointment", True) else 0, b.get("image_path"),
         b.get("meta_title"), b.get("meta_description"), 1 if b.get("is_published") else 0,
         int(b.get("sort_order") or 0), b.get("problem_html"), b.get("process_html"), b.get("who_for_html"),
         b.get("benefits_html"), b.get("documents_html"), m.group(1)))
    if "faqs" in b:
        db.execute("DELETE FROM service_faq WHERE service_id = ?", (m.group(1),))
        for i, f in enumerate(b["faqs"] or []):
            if f.get("question") and f.get("answer"):
                db.execute("INSERT INTO service_faq (service_id, question, answer, sort_order) VALUES (?,?,?,?)",
                          (m.group(1), f["question"], f["answer"], i))
    db.audit(u["id"], "update", "service", m.group(1))
    return admin_get_service(env, m)


@route("DELETE", r"/api/admin/services/(\d+)")
def admin_delete_service(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    db.execute("DELETE FROM service WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]))
    db.audit(u["id"], "delete", "service", m.group(1))
    return {"ok": True}


@route("GET", r"/api/admin/pages")
def admin_list_pages(env, m):
    u = _user(env)
    return db.query("SELECT * FROM page WHERE firm_id = ? ORDER BY title", (u["firm_id"],))


@route("POST", r"/api/admin/pages")
def admin_create_page(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    title = (b.get("title") or "").strip()
    slug = _slugify(b.get("slug") or title)
    if not title:
        raise HttpError(400, "A title is required")
    pid = _execute_or_slug_conflict(
        "INSERT INTO page (firm_id, slug, title, content_md, meta_title, meta_description, is_published, updated_by)"
        " VALUES (?,?,?,?,?,?,?,?)",
        (u["firm_id"], slug, title, b.get("content_md"), b.get("meta_title"), b.get("meta_description"),
         1 if b.get("is_published") else 0, u["id"]))
    db.audit(u["id"], "create", "page", pid, title)
    return db.query("SELECT * FROM page WHERE id = ?", (pid,), one=True)


@route("PUT", r"/api/admin/pages/(\d+)")
def admin_update_page(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT id FROM page WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That page no longer exists")
    b = _json_body(env)
    _execute_or_slug_conflict(
        "UPDATE page SET slug=?, title=?, content_md=?, meta_title=?, meta_description=?, is_published=?,"
        " updated_at=datetime('now'), updated_by=? WHERE id=?",
        (_slugify(b.get("slug") or b.get("title")), b.get("title"), b.get("content_md"), b.get("meta_title"), b.get("meta_description"),
         1 if b.get("is_published") else 0, u["id"], m.group(1)))
    db.audit(u["id"], "update", "page", m.group(1))
    return db.query("SELECT * FROM page WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/pages/(\d+)")
def admin_delete_page(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    db.execute("DELETE FROM page WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]))
    db.audit(u["id"], "delete", "page", m.group(1))
    return {"ok": True}


@route("GET", r"/api/admin/testimonials")
def admin_list_testimonials(env, m):
    u = _user(env)
    return db.query("SELECT * FROM testimonial WHERE firm_id = ? ORDER BY sort_order, id DESC", (u["firm_id"],))


@route("POST", r"/api/admin/testimonials")
def admin_create_testimonial(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    client_name = (b.get("client_name") or "").strip()
    content = (b.get("content") or "").strip()
    if not client_name or not content:
        raise HttpError(400, "Client name and testimonial text are required")
    tid = db.execute(
        "INSERT INTO testimonial (firm_id, client_name, client_title, company, content, rating, is_published, sort_order)"
        " VALUES (?,?,?,?,?,?,?,?)",
        (u["firm_id"], client_name, b.get("client_title"), b.get("company"), content, b.get("rating"),
         1 if b.get("is_published") else 0, int(b.get("sort_order") or 0)))
    db.audit(u["id"], "create", "testimonial", tid, client_name)
    return db.query("SELECT * FROM testimonial WHERE id = ?", (tid,), one=True)


@route("PUT", r"/api/admin/testimonials/(\d+)")
def admin_update_testimonial(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT id FROM testimonial WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That testimonial no longer exists")
    b = _json_body(env)
    # Partial update — a field the caller didn't send (e.g. the quick
    # Publish/Unpublish button, which sends only is_published) must keep its
    # existing value, not get overwritten with NULL. client_name/content are
    # NOT NULL, so the old blind full-row UPDATE here would throw on every
    # publish-toggle click and silently fail the whole request.
    sets, args = [], []
    for field in ("client_name", "client_title", "company", "content", "rating", "sort_order"):
        if field in b:
            sets.append(f"{field} = ?")
            args.append(int(b[field]) if field == "sort_order" and b[field] is not None else b[field])
    if "is_published" in b:
        sets.append("is_published = ?"); args.append(1 if b["is_published"] else 0)
    if sets:
        args.append(m.group(1))
        db.execute(f"UPDATE testimonial SET {', '.join(sets)} WHERE id = ?", args)
    db.audit(u["id"], "update", "testimonial", m.group(1))
    return db.query("SELECT * FROM testimonial WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/testimonials/(\d+)")
def admin_delete_testimonial(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    db.execute("DELETE FROM testimonial WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]))
    db.audit(u["id"], "delete", "testimonial", m.group(1))
    return {"ok": True}


def _clean_maps_embed_url(raw):
    """The Contact section drops this straight into an <iframe src="…">
    (see landing.js renderContactInfo), so it must be a bare, safe URL —
    never a chunk of someone else's HTML. Handles the two things people
    actually paste here: (1) Google's own "Share > Embed a map" gives a
    full <iframe ...> tag, not a bare URL — pull the src out of it rather
    than making everyone extract it by hand; (2) third-party "free map
    embed generator" sites (found in the wild in this app's own data) wrap
    the iframe in extra markup that can carry a hidden tracking/spam
    backlink — reject anything that isn't a plain URL on a Google Maps
    domain rather than trust arbitrary pasted HTML."""
    raw = (raw or "").strip()
    if not raw:
        return None
    if "<" in raw:
        match = re.search(r'src=["\']([^"\']+)["\']', raw, re.IGNORECASE)
        if not match:
            raise HttpError(400, "Couldn't find a map link in what was pasted — paste just the map link, or "
                                 "Google Maps' own \"Share > Embed a map\" code, not a third-party embed generator")
        raw = match.group(1)
    if not re.match(r'^https://(www\.)?(google\.[a-z.]+/maps|maps\.google\.[a-z.]+)/', raw, re.IGNORECASE):
        raise HttpError(400, "That doesn't look like a Google Maps link — use Google Maps' own "
                             "\"Share > Embed a map\" feature, not a third-party embed generator")
    return raw


@route("GET", r"/api/admin/active-countries")
def admin_active_countries(env, m):
    # The jurisdictions with real rate_master/tp_regulatory_rule content —
    # NOT the full tp_country list (that one's ~195 rows are location-only
    # reference data for a related party's country, see tp_country's own
    # seed comment). A firm should only ever set its own (or a client's own)
    # country_code to one of these; anything else has zero rate content
    # behind it. Any authenticated user, not just site_settings — this is
    # non-sensitive reference data (country name/code/currency), and
    # picking a client's own country is an ordinary client-management
    # action, not a site-admin one.
    _user(env)
    return [dict(c) for c in country_service.active_countries()]


@route("PUT", r"/api/admin/firm-country")
def admin_update_firm_country(env, m):
    # Deliberately separate from PUT /api/admin/firm-profile below: that
    # endpoint always rewrites every one of its 14 fields from the request
    # body (b.get(f), defaulting missing fields to NULL) — sending just
    # {"country_code": ...} through it would silently blank the firm's name/
    # address/phone/socials (and 500 outright, since firm.name is NOT NULL).
    # This is the switch the Admin page's top-of-page country selector
    # calls — gated on is_super_admin like the rest of firm self-management
    # (_require_admin), not on the site_settings module grant, since a
    # firm's own super admin must always be able to set their firm's own
    # jurisdiction regardless of which modules their plan happens to include.
    u = _user(env)
    _require_admin(u)
    b = _json_body(env)
    country_code = (b.get("country_code") or "").strip()
    valid = {c["code"] for c in country_service.active_countries()}
    if country_code not in valid:
        raise HttpError(400, f"Unknown or unsupported country_code {country_code!r} "
                              f"— must be one of {sorted(valid)}")
    db.execute("UPDATE firm SET country_code = ? WHERE id = ?", (country_code, u["firm_id"]))
    db.audit(u["id"], "update", "firm", u["firm_id"], f"country_code -> {country_code}")
    return db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True)


@route("PUT", r"/api/admin/firm-profile")
def admin_update_firm_profile(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    fields = ("name", "short_name", "tagline", "pan", "address", "phone", "email", "website",
             "google_maps_url", "google_maps_embed", "office_hours",
             "facebook_url", "linkedin_url", "twitter_url", "instagram_url", "country_code")
    values = {f: b.get(f) for f in fields}
    values["google_maps_embed"] = _clean_maps_embed_url(values["google_maps_embed"])
    # country_code is NOT NULL — an omitted/blank value (an older cached
    # frontend, or a direct API call that predates this field) keeps
    # whatever the firm already has instead of failing the UPDATE.
    if not values["country_code"]:
        values["country_code"] = country_service.firm_country_code(u["firm_id"])
    else:
        valid = {c["code"] for c in country_service.active_countries()}
        if values["country_code"] not in valid:
            raise HttpError(400, f"Unknown or unsupported country_code {values['country_code']!r} "
                                  f"— must be one of {sorted(valid)}")
    sets = ", ".join(f"{f} = ?" for f in fields)
    db.execute(f"UPDATE firm SET {sets} WHERE id = ?", tuple(values[f] for f in fields) + (u["firm_id"],))
    db.audit(u["id"], "update", "firm", u["firm_id"], "profile")
    return db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True)


# ----------------------------------------------------- admin: SMTP / email log
# The password is write-only from the API's point of view: GET never returns
# it (has_password tells the form whether one is already set), and PUT only
# touches it when a new value is actually supplied — leaving the field blank
# in the admin form keeps whatever is already stored.

@route("GET", r"/api/admin/smtp")
def admin_get_smtp(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT * FROM smtp_config WHERE firm_id = ?", (u["firm_id"],), one=True)
    if not row:
        return {"host": "", "port": 587, "username": "", "from_name": "", "from_email": "",
                "use_tls": True, "has_password": False, "env_fallback_active": bool(os.environ.get("CA_SMTP_HOST"))}
    d = dict(row)
    d["has_password"] = bool(d.pop("password_enc", None))
    d["env_fallback_active"] = False
    return d


@route("PUT", r"/api/admin/smtp")
def admin_update_smtp(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    existing = db.query("SELECT password_enc FROM smtp_config WHERE firm_id = ?", (u["firm_id"],), one=True)
    password_enc = (existing or {}).get("password_enc")
    if b.get("password"):
        password_enc = db.encrypt_secret(b["password"])
    db.execute(
        "INSERT INTO smtp_config (firm_id, host, port, username, password_enc, from_name, from_email, use_tls, updated_by)"
        " VALUES (?,?,?,?,?,?,?,?,?)"
        " ON CONFLICT(firm_id) DO UPDATE SET host=excluded.host, port=excluded.port, username=excluded.username,"
        " password_enc=excluded.password_enc, from_name=excluded.from_name, from_email=excluded.from_email,"
        " use_tls=excluded.use_tls, updated_at=datetime('now'), updated_by=excluded.updated_by",
        (u["firm_id"], (b.get("host") or "").strip(), int(b.get("port") or 587), (b.get("username") or "").strip(),
         password_enc, (b.get("from_name") or "").strip(), (b.get("from_email") or "").strip(),
         1 if b.get("use_tls", True) else 0, u["id"]))
    db.audit(u["id"], "update", "smtp_config", u["firm_id"], "SMTP settings")
    return {"ok": True}


@route("POST", r"/api/admin/smtp/test")
def admin_test_smtp(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    to = (b.get("to") or u["email"] or "").strip()
    if not to:
        raise HttpError(400, "No recipient address to send the test to")
    ok = notify.send_email(to, "CA Automator — SMTP test",
                           "This is a test email from CA Automator's Admin > Settings > Email screen. "
                           "If you received this, outbound email is configured correctly.")
    if not ok:
        raise HttpError(400, "Send failed — check the SMTP host/port/credentials and try again")
    return {"ok": True}


# ---------------------------------------------------------------- site banner
# One config per firm (like smtp_config) — a landing-page popup notice.
# is_active is the on/off switch; display_seconds is the auto-dismiss timer
# read by the frontend (0 = stays until the visitor closes it).

@route("GET", r"/api/admin/banner")
def admin_get_banner(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT * FROM site_banner WHERE firm_id = ?", (u["firm_id"],), one=True)
    if not row:
        return {"title": "", "message": "", "image_path": "", "link_url": "", "link_label": "",
                "display_seconds": 0, "is_active": False}
    return dict(row)


@route("PUT", r"/api/admin/banner")
def admin_update_banner(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    db.execute(
        "INSERT INTO site_banner (firm_id, title, message, image_path, link_url, link_label, display_seconds,"
        " is_active, updated_by) VALUES (?,?,?,?,?,?,?,?,?)"
        " ON CONFLICT(firm_id) DO UPDATE SET title=excluded.title, message=excluded.message,"
        " image_path=excluded.image_path, link_url=excluded.link_url, link_label=excluded.link_label,"
        " display_seconds=excluded.display_seconds, is_active=excluded.is_active,"
        " updated_at=datetime('now'), updated_by=excluded.updated_by",
        (u["firm_id"], (b.get("title") or "").strip(), (b.get("message") or "").strip(),
         (b.get("image_path") or "").strip(), (b.get("link_url") or "").strip(), (b.get("link_label") or "").strip(),
         int(b.get("display_seconds") or 0), 1 if b.get("is_active") else 0, u["id"]))
    db.audit(u["id"], "update", "site_banner", u["firm_id"], "Banner settings")
    return {"ok": True}


@route("GET", r"/api/public/banner")
def public_get_banner(env, m):
    row = db.query("SELECT title, message, image_path, link_url, link_label, display_seconds"
                   " FROM site_banner WHERE is_active = 1 AND firm_id = ?", (_default_firm_id(),), one=True)
    return dict(row) if row else None


# ------------------------------------------------------- admin: AI providers
# Powers both the public chatbot (site_chat.py) and the internal CA
# Assistant (ca_assistant.py) — see ai_provider.py for the resolution order
# (a DB row here wins over GROQ_API_KEY/ANTHROPIC_API_KEY env vars). No
# restart needed: a key saved here is live on the next chat message. The
# provider list is driven by ai_provider.available_providers() — the set of
# providers this build actually knows how to call — so a future provider
# just needs one _call_<name>() function added there, no frontend change.

@route("GET", r"/api/admin/ai-providers")
def admin_list_ai_providers(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    return {
        "available": ai_provider.available_providers(),
        "configured": ai_provider.list_keys(u["firm_id"]),
        "status": ai_provider.status(u["firm_id"]),
    }


@route("PUT", r"/api/admin/ai-providers/([a-z0-9_-]+)")
def admin_set_ai_provider_key(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    try:
        ai_provider.set_key(u["firm_id"], m.group(1), b.get("api_key"), model=b.get("model"),
                            make_active=b.get("make_active", True), updated_by=u["id"])
    except ai_provider.ProviderError as e:
        raise HttpError(400, str(e))
    db.audit(u["id"], "update", "ai_provider_key", m.group(1), "API key saved")
    return {"ok": True, "configured": ai_provider.list_keys(u["firm_id"])}


@route("DELETE", r"/api/admin/ai-providers/([a-z0-9_-]+)")
def admin_delete_ai_provider_key(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    ai_provider.delete_key(u["firm_id"], m.group(1))
    db.audit(u["id"], "delete", "ai_provider_key", m.group(1))
    return {"ok": True, "configured": ai_provider.list_keys(u["firm_id"])}


@route("POST", r"/api/admin/ai-providers/([a-z0-9_-]+)/activate")
def admin_activate_ai_provider(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    try:
        ai_provider.set_active(u["firm_id"], m.group(1))
    except ai_provider.ProviderError as e:
        raise HttpError(400, str(e))
    db.audit(u["id"], "update", "ai_provider_key", m.group(1), "marked active")
    return {"ok": True, "configured": ai_provider.list_keys(u["firm_id"])}


# --------------------------------------------------- admin: social login
# Client-portal "Sign in with Google" (etc.) credentials — see
# oauth_login.py for the full security posture. Same admin-CRUD shape as
# AI Providers: list (masked), set (Client ID + Secret), delete.

@route("GET", r"/api/admin/oauth-providers")
def admin_list_oauth_providers(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    return {"available": oauth_login.available_providers(), "configured": oauth_login.list_keys(u["firm_id"])}


@route("PUT", r"/api/admin/oauth-providers/([a-z0-9_-]+)")
def admin_set_oauth_provider_key(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    try:
        oauth_login.set_key(u["firm_id"], m.group(1), b.get("client_id"), b.get("client_secret"),
                            is_active=b.get("is_active", True), updated_by=u["id"])
    except oauth_login.OAuthError as e:
        raise HttpError(400, str(e))
    db.audit(u["id"], "update", "oauth_provider_key", m.group(1), "Social login credentials saved")
    return {"ok": True, "configured": oauth_login.list_keys(u["firm_id"])}


@route("DELETE", r"/api/admin/oauth-providers/([a-z0-9_-]+)")
def admin_delete_oauth_provider_key(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    oauth_login.delete_key(u["firm_id"], m.group(1))
    db.audit(u["id"], "delete", "oauth_provider_key", m.group(1))
    return {"ok": True, "configured": oauth_login.list_keys(u["firm_id"])}


# ------------------------------------------------------------- admin: menu
# The site's nav — grouped by section (the "field" each item belongs to:
# primary top nav vs. footer link column), sorted within each section by
# sort_order. Reordering is two adjacent-row swaps (move up/down), not a
# drag-and-drop endpoint — simple, no extra client library, and correct
# either way since sort_order only has to be consistently ordered, not
# contiguous.

@route("GET", r"/api/admin/menu")
def admin_list_menu(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    return db.query("SELECT * FROM nav_menu_item WHERE firm_id = ? ORDER BY section, sort_order", (u["firm_id"],))


@route("POST", r"/api/admin/menu")
def admin_create_menu_item(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    label = (b.get("label") or "").strip()
    url = (b.get("url") or "").strip()
    section = b.get("section") or "primary"
    if not label or not url:
        raise HttpError(400, "A label and a URL are required")
    if section not in ("primary", "footer"):
        raise HttpError(400, "section must be 'primary' or 'footer'")
    next_order = db.query("SELECT COALESCE(MAX(sort_order), -1) + 1 n FROM nav_menu_item WHERE firm_id = ? AND section = ?",
                          (u["firm_id"], section), one=True)["n"]
    mid = db.execute(
        "INSERT INTO nav_menu_item (firm_id, section, label, url, sort_order, is_visible) VALUES (?,?,?,?,?,?)",
        (u["firm_id"], section, label, url, next_order, 1 if b.get("is_visible", True) else 0))
    db.audit(u["id"], "create", "nav_menu_item", mid, label)
    return db.query("SELECT * FROM nav_menu_item WHERE id = ?", (mid,), one=True)


@route("PUT", r"/api/admin/menu/(\d+)")
def admin_update_menu_item(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT id FROM nav_menu_item WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That menu item no longer exists")
    b = _json_body(env)
    sets, args = [], []
    for field in ("label", "url", "section", "sort_order"):
        if field in b:
            sets.append(f"{field} = ?"); args.append(b[field])
    if "is_visible" in b:
        sets.append("is_visible = ?"); args.append(1 if b["is_visible"] else 0)
    if not sets:
        raise HttpError(400, "Nothing to update")
    sets.append("updated_at = datetime('now')")
    db.execute(f"UPDATE nav_menu_item SET {', '.join(sets)} WHERE id = ?", args + [m.group(1)])
    db.audit(u["id"], "update", "nav_menu_item", m.group(1))
    return db.query("SELECT * FROM nav_menu_item WHERE id = ?", (m.group(1),), one=True)


@route("POST", r"/api/admin/menu/(\d+)/move")
def admin_move_menu_item(env, m):
    """direction: 'up' or 'down' — swaps sort_order with the adjacent item
    in the same section. A no-op (not an error) at either end of the list."""
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    item = db.query("SELECT * FROM nav_menu_item WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not item:
        raise HttpError(404, "That menu item no longer exists")
    op = "<" if b.get("direction") == "up" else ">"
    order_by = "sort_order DESC" if b.get("direction") == "up" else "sort_order ASC"
    neighbor = db.query(
        f"SELECT * FROM nav_menu_item WHERE firm_id = ? AND section = ? AND sort_order {op} ?"
        f" ORDER BY {order_by} LIMIT 1",
        (u["firm_id"], item["section"], item["sort_order"]), one=True)
    if neighbor:
        db.execute("UPDATE nav_menu_item SET sort_order = ? WHERE id = ?", (neighbor["sort_order"], item["id"]))
        db.execute("UPDATE nav_menu_item SET sort_order = ? WHERE id = ?", (item["sort_order"], neighbor["id"]))
    return db.query("SELECT * FROM nav_menu_item WHERE firm_id = ? ORDER BY section, sort_order", (u["firm_id"],))


@route("DELETE", r"/api/admin/menu/(\d+)")
def admin_delete_menu_item(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    db.execute("DELETE FROM nav_menu_item WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]))
    db.audit(u["id"], "delete", "nav_menu_item", m.group(1))
    return {"ok": True}


@route("GET", r"/api/admin/email-log")
def admin_email_log(env, m):
    u = _user(env)
    _require_admin(u)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    limit = min(int((qs.get("limit") or ["100"])[0]), 500)
    return db.query("SELECT * FROM email_log ORDER BY created_at DESC LIMIT ?", (limit,))


# ------------------------------------------------------------------ payments
# Khalti and ConnectIPS — both entirely optional, both blank until a super
# admin fills in real credentials here. See server/payments.py for the
# actual gateway calls; these routes only ever store/mask config and turn a
# payments.PaymentError into a plain 400, never a 500.

@route("GET", r"/api/admin/payment-gateway")
def admin_get_payment_gateway(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT * FROM payment_gateway_config WHERE firm_id = ?", (u["firm_id"],), one=True)
    if not row:
        return {"khalti_public_key": "", "khalti_mode": "test", "has_khalti_key": False,
                "connectips_merchant_id": "", "connectips_app_id": "", "connectips_app_name": "",
                "connectips_mode": "test", "has_connectips_password": False, "has_connectips_cert": False,
                "crypto_available": payments._CRYPTO_AVAILABLE}
    d = dict(row)
    d["has_khalti_key"] = bool(d.pop("khalti_secret_key_enc", None))
    d["has_connectips_password"] = bool(d.pop("connectips_password_enc", None))
    d["has_connectips_cert"] = bool(d.pop("connectips_cert_pass_enc", None) or row["connectips_cert_path"])
    d["crypto_available"] = payments._CRYPTO_AVAILABLE
    return d


@route("PUT", r"/api/admin/payment-gateway")
def admin_update_payment_gateway(env, m):
    """Write-only for every secret field, exactly like SMTP: a blank value
    in the request means "leave whatever's already stored" — the only way
    to actually clear a credential is to reconfigure it with a new one, so
    an admin can never accidentally wipe working payment config by saving
    the form with an empty field it never round-tripped in the first
    place."""
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    existing = db.query("SELECT * FROM payment_gateway_config WHERE firm_id = ?", (u["firm_id"],), one=True)
    khalti_key_enc = (existing["khalti_secret_key_enc"] if existing else None)
    if b.get("khalti_secret_key"):
        khalti_key_enc = db.encrypt_secret(b["khalti_secret_key"])
    connectips_pass_enc = (existing["connectips_password_enc"] if existing else None)
    if b.get("connectips_password"):
        connectips_pass_enc = db.encrypt_secret(b["connectips_password"])
    db.execute(
        "INSERT INTO payment_gateway_config (firm_id, khalti_secret_key_enc, khalti_public_key, khalti_mode,"
        " connectips_merchant_id, connectips_app_id, connectips_app_name, connectips_password_enc, connectips_mode,"
        " updated_by) VALUES (?,?,?,?,?,?,?,?,?,?)"
        " ON CONFLICT(firm_id) DO UPDATE SET khalti_secret_key_enc=excluded.khalti_secret_key_enc,"
        " khalti_public_key=excluded.khalti_public_key, khalti_mode=excluded.khalti_mode,"
        " connectips_merchant_id=excluded.connectips_merchant_id, connectips_app_id=excluded.connectips_app_id,"
        " connectips_app_name=excluded.connectips_app_name, connectips_password_enc=excluded.connectips_password_enc,"
        " connectips_mode=excluded.connectips_mode, updated_at=datetime('now'), updated_by=excluded.updated_by",
        (u["firm_id"], khalti_key_enc, (b.get("khalti_public_key") or "").strip(), b.get("khalti_mode") or "test",
         (b.get("connectips_merchant_id") or "").strip(), (b.get("connectips_app_id") or "").strip(),
         (b.get("connectips_app_name") or "").strip(), connectips_pass_enc, b.get("connectips_mode") or "test",
         u["id"]))
    db.audit(u["id"], "update", "payment_gateway_config", u["firm_id"], "payment gateway settings")
    return {"ok": True}


@route("POST", r"/api/admin/payment-gateway/connectips-cert")
def admin_upload_connectips_cert(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    try:
        blob = base64.b64decode(b.get("content_base64") or "")
    except Exception:
        raise HttpError(400, "The certificate content was not valid")
    if not blob:
        raise HttpError(400, "No certificate content received")
    os.makedirs(CERT_DIR, exist_ok=True)
    path = os.path.join(CERT_DIR, f"connectips-{u['firm_id']}.pfx")
    with open(path, "wb") as f:
        f.write(blob)
    os.chmod(path, 0o600)
    cert_pass_enc = db.encrypt_secret(b["cert_password"]) if b.get("cert_password") else None
    db.execute(
        "INSERT INTO payment_gateway_config (firm_id, connectips_cert_path, connectips_cert_pass_enc, updated_by)"
        " VALUES (?,?,?,?)"
        " ON CONFLICT(firm_id) DO UPDATE SET connectips_cert_path=excluded.connectips_cert_path,"
        " connectips_cert_pass_enc=COALESCE(excluded.connectips_cert_pass_enc, payment_gateway_config.connectips_cert_pass_enc),"
        " updated_at=datetime('now'), updated_by=excluded.updated_by",
        (u["firm_id"], path, cert_pass_enc, u["id"]))
    db.audit(u["id"], "update", "payment_gateway_config", u["firm_id"], "ConnectIPS certificate uploaded")
    return {"ok": True}


@route("GET", r"/api/admin/payment-transactions")
def admin_list_payment_transactions(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    qs = parse_qs(env.get("QUERY_STRING", ""))
    limit = min(int((qs.get("limit") or ["100"])[0]), 500)
    return db.query(
        "SELECT t.*, c.name client_name FROM payment_transaction t LEFT JOIN client c ON c.id = t.client_id"
        " WHERE t.firm_id = ? ORDER BY t.created_at DESC LIMIT ?", (u["firm_id"], limit))


def _record_payment_txn(firm_id, gateway, reference_type, reference_id, client_id, amount, gateway_ref):
    return db.execute(
        "INSERT INTO payment_transaction (firm_id, gateway, reference_type, reference_id, client_id, amount,"
        " gateway_ref, status) VALUES (?,?,?,?,?,?,?, 'pending')",
        (firm_id, gateway, reference_type, reference_id, client_id, amount, gateway_ref))


def _settle_payment_txn(txn_id, success, raw_response):
    db.execute(
        "UPDATE payment_transaction SET status = ?, raw_response = ?, updated_at = datetime('now') WHERE id = ?",
        ("success" if success else "failed", json.dumps(raw_response, default=str), txn_id))
    if not success:
        return
    txn = db.query("SELECT * FROM payment_transaction WHERE id = ?", (txn_id,), one=True)
    if txn["reference_type"] == "invoice":
        inv = db.query("SELECT total FROM invoice WHERE id = ?", (txn["reference_id"],), one=True)
        if inv:
            db.execute("UPDATE invoice SET status = 'paid', paid_amount = ?, paid_date = date('now') WHERE id = ?",
                      (inv["total"], txn["reference_id"]))
    elif txn["reference_type"] == "service_order":
        db.execute("UPDATE service_order SET status = 'paid' WHERE id = ?", (txn["reference_id"],))


@route("POST", r"/api/portal/invoices/(\d+)/pay")
def portal_pay_invoice(env, m):
    pu = _portal_user(env)
    inv = db.query("SELECT * FROM invoice WHERE id = ? AND client_id = ?", (m.group(1), pu["client_id"]), one=True)
    if not inv:
        raise HttpError(404, "That invoice no longer exists")
    if inv["status"] == "paid":
        raise HttpError(400, "This invoice is already paid")
    b = _json_body(env)
    gateway = b.get("gateway")
    amount = inv["total"]
    base = _base_url(env)
    try:
        if gateway == "khalti":
            ref = _gen_ref_id("INV")
            txn_id = _record_payment_txn(pu["firm_id"], "khalti", "invoice", inv["id"], pu["client_id"], amount, ref)
            res = payments.khalti_initiate(
                amount, ref, f"Invoice {inv['invoice_no']}",
                return_url=f"{base}/api/payments/khalti/callback?txn={txn_id}", website_url=base,
                customer_name=pu["name"], customer_email=pu["email"])
            db.execute("UPDATE payment_transaction SET gateway_txn_id = ? WHERE id = ?", (res.get("pidx"), txn_id))
            return {"gateway": "khalti", "payment_url": res["payment_url"]}
        elif gateway == "connectips":
            ref = _gen_ref_id("INV")
            txn_id = _record_payment_txn(pu["firm_id"], "connectips", "invoice", inv["id"], pu["client_id"], amount, ref)
            res = payments.connectips_build_redirect(
                amount, txn_id=str(txn_id), reference_id=ref, remarks="Invoice payment",
                particulars=f"Invoice {inv['invoice_no']}",
                return_url=f"{base}/api/payments/connectips/callback")
            return {"gateway": "connectips", "action_url": res["action_url"], "fields": res["fields"]}
        else:
            raise HttpError(400, "Choose a payment method")
    except payments.PaymentError as e:
        raise HttpError(400, str(e))


def _gen_ref_id(prefix):
    return f"{prefix}-{db.now_iso()[:10].replace('-', '')}-{secrets.token_hex(4)}"


@route("POST", r"/api/public/services/([a-z0-9-]+)/checkout")
def public_service_checkout(env, m):
    service = db.query("SELECT * FROM service WHERE slug = ? AND is_published = 1 AND is_buyable = 1",
                       (m.group(1),), one=True)
    if not service:
        raise HttpError(404, "That service isn't available for purchase")
    if not service["price"]:
        raise HttpError(400, "This service doesn't have a fixed price — get in touch instead")
    b = _json_body(env)
    name = (b.get("name") or "").strip()
    email = (b.get("email") or "").strip()
    if not name or not email:
        raise HttpError(400, "Name and email are required")
    gateway = b.get("gateway")
    portal_client_id = None
    portal_auth = env.get("HTTP_AUTHORIZATION", "")
    if portal_auth.lower().startswith("bearer "):
        pu = db.portal_user_for_token(portal_auth[7:].strip())
        if pu:
            portal_client_id = pu["client_id"]
    oid = db.execute(
        "INSERT INTO service_order (firm_id, service_id, client_id, name, email, phone, amount, currency)"
        " VALUES (?,?,?,?,?,?,?,?)",
        (service["firm_id"], service["id"], portal_client_id, name, email, (b.get("phone") or "").strip(),
         service["price"], service["currency"]))
    base = _base_url(env)
    try:
        if gateway == "khalti":
            ref = _gen_ref_id("SVC")
            txn_id = _record_payment_txn(service["firm_id"], "khalti", "service_order", oid, portal_client_id,
                                         service["price"], ref)
            res = payments.khalti_initiate(
                service["price"], ref, service["name"],
                return_url=f"{base}/api/payments/khalti/callback?txn={txn_id}", website_url=base,
                customer_name=name, customer_email=email, customer_phone=b.get("phone"))
            db.execute("UPDATE payment_transaction SET gateway_txn_id = ? WHERE id = ?", (res.get("pidx"), txn_id))
            return {"order_id": oid, "gateway": "khalti", "payment_url": res["payment_url"]}
        elif gateway == "connectips":
            ref = _gen_ref_id("SVC")
            txn_id = _record_payment_txn(service["firm_id"], "connectips", "service_order", oid, portal_client_id,
                                         service["price"], ref)
            res = payments.connectips_build_redirect(
                service["price"], txn_id=str(txn_id), reference_id=ref, remarks="Service purchase",
                particulars=service["name"], return_url=f"{base}/api/payments/connectips/callback")
            return {"order_id": oid, "gateway": "connectips", "action_url": res["action_url"], "fields": res["fields"]}
        else:
            raise HttpError(400, "Choose a payment method")
    except payments.PaymentError as e:
        raise HttpError(400, str(e))


def _payment_result_redirect(success, reference_type):
    # Query string has to come before the #fragment, not after — a browser
    # never sends anything past # back to the server, and this URL only
    # gets parsed client-side, so the order matters for location.search to
    # actually see it.
    q = "payment=success" if success else "payment=failed"
    return f"/portal.html?{q}#/" if reference_type == "invoice" else f"/?{q}"


@route("GET", r"/api/payments/khalti/callback")
def khalti_callback(env, m):
    qs = parse_qs(env.get("QUERY_STRING", ""))
    txn_id = (qs.get("txn") or [None])[0]
    pidx = (qs.get("pidx") or [None])[0]
    txn = db.query("SELECT * FROM payment_transaction WHERE id = ?", (txn_id,), one=True) if txn_id else None
    success = False
    result = {}
    if txn and pidx:
        try:
            result = payments.khalti_verify(pidx)
            success = result.get("status") == "Completed"
        except payments.PaymentError as e:
            result = {"error": str(e)}
        _settle_payment_txn(int(txn_id), success, result)
    location = _payment_result_redirect(success, txn["reference_type"] if txn else "service_order")
    return ("__redirect__", location)


@route("GET", r"/api/payments/connectips/callback")
def connectips_callback_get(env, m):
    return _connectips_callback(env)


@route("POST", r"/api/payments/connectips/callback")
def connectips_callback_post(env, m):
    return _connectips_callback(env)


def _connectips_callback(env):
    qs = parse_qs(env.get("QUERY_STRING", ""))
    txn_id_field = (qs.get("TXNID") or [None])[0]
    ref_id = (qs.get("REFERENCEID") or [None])[0]
    txn = db.query("SELECT * FROM payment_transaction WHERE id = ?", (txn_id_field,), one=True) if txn_id_field else None
    success = False
    result = {}
    if txn and ref_id:
        try:
            result = payments.connectips_verify(txn_id_field, ref_id, txn["amount"])
            success = str(result.get("status") or result.get("STATUS") or "").upper() in ("SUCCESS", "S")
        except payments.PaymentError as e:
            result = {"error": str(e)}
        _settle_payment_txn(int(txn_id_field), success, result)
    location = _payment_result_redirect(success, txn["reference_type"] if txn else "service_order")
    return ("__redirect__", location)


# ------------------------------------------------------------ admin: email templates
# Admin-editable {{placeholder}} bodies — see notify.render_template() and
# _CAREER_STAGE_TEMPLATE below for where these actually get used. The set of
# keys is fixed (seeded by db._ensure_email_templates on every boot); this
# screen only ever updates a row, never creates or deletes one.

@route("GET", r"/api/admin/email-templates")
def admin_list_email_templates(env, m):
    u = _user(env)
    _require_admin(u)
    return db.query("SELECT * FROM email_template ORDER BY `key`")


@route("PUT", r"/api/admin/email-templates/([a-z_]+)")
def admin_update_email_template(env, m):
    u = _user(env)
    _require_admin(u)
    if not db.query("SELECT 1 FROM email_template WHERE `key` = ?", (m.group(1),), one=True):
        raise HttpError(404, "Unknown template key")
    b = _json_body(env)
    subject = (b.get("subject") or "").strip()
    body = (b.get("body") or "").strip()
    if not subject or not body:
        raise HttpError(400, "Subject and body are required")
    db.execute("UPDATE email_template SET subject = ?, body = ?, updated_at = datetime('now'), updated_by = ?"
              " WHERE `key` = ?", (subject, body, u["id"], m.group(1)))
    db.audit(u["id"], "update", "email_template", m.group(1))
    return db.query("SELECT * FROM email_template WHERE `key` = ?", (m.group(1),), one=True)


# ----------------------------------------------------------------------- blog
# blog_category/blog_post/blog_faq/blog_internal_link already existed
# (seeded, six articles ready in content/blog/) but had no rendering layer —
# server/public_pages.py now renders /blog and /blog/<slug> as real,
# crawlable HTML for search, and these JSON routes back the small "Latest
# articles" preview on the landing page plus the full admin editor below.

@route("GET", r"/api/public/blog")
def public_list_blog(env, m):
    qs = parse_qs(env.get("QUERY_STRING", ""))
    limit = min(int((qs.get("limit") or ["6"])[0]), 50)
    posts = db.query(
        "SELECT p.slug, p.title, p.h1, p.meta_description, p.published_at, p.reading_minutes, p.og_image_path,"
        " c.name category_name FROM blog_post p LEFT JOIN blog_category c ON c.id = p.category_id"
        " WHERE p.status = 'published' ORDER BY p.published_at DESC LIMIT ?", (limit,))
    return posts


@route("POST", r"/api/admin/media")
def admin_upload_media(env, m):
    """A blog cover image (today; any future admin-uploaded public image
    tomorrow) — deliberately restricted to a small allowlist of real image
    types, not "whatever the browser says it is": the browser-reported MIME
    is a hint, so this also sanity-checks the file's own magic bytes before
    trusting it, the same discipline a document upload would want if it
    were ever served back out publicly instead of behind auth."""
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    content_type = b.get("content_type") or ""
    if content_type not in _ALLOWED_IMAGE_TYPES:
        raise HttpError(400, "Only JPEG, PNG or WebP images are allowed")
    try:
        blob = base64.b64decode(b.get("content_base64") or "")
    except Exception:
        raise HttpError(400, "The image content was not valid")
    if not blob:
        raise HttpError(400, "No image content received")
    if len(blob) > 5 * 1024 * 1024:
        raise HttpError(400, "Images must be 5MB or smaller")
    _MAGIC = (
        (b"\xff\xd8\xff", "image/jpeg"), (b"\x89PNG\r\n\x1a\n", "image/png"), (b"RIFF", "image/webp"),
    )
    if not any(blob.startswith(sig) for sig, _ in _MAGIC):
        raise HttpError(400, "That file doesn't look like a real image")
    os.makedirs(MEDIA_DIR, exist_ok=True)
    ext = _ALLOWED_IMAGE_TYPES[content_type]
    fname = secrets.token_hex(16) + ext
    with open(os.path.join(MEDIA_DIR, fname), "wb") as f:
        f.write(blob)
    db.execute(
        "INSERT INTO media_asset (firm_id, filename, original_filename, mime_type, file_size, uploaded_by)"
        " VALUES (?,?,?,?,?,?)",
        (u["firm_id"], fname, b.get("filename") or fname, content_type, len(blob), u["id"]))
    db.audit(u["id"], "create", "media", fname, b.get("filename") or fname)
    return {"url": "/media/" + fname}


@route("GET", r"/api/admin/media")
def admin_list_media(env, m):
    """The Media Gallery — every image ever uploaded via POST /api/admin/media,
    newest first, so an admin can reuse an existing image's URL in a
    different field (paste it straight into the URL box next to any image
    upload control) instead of re-uploading the same file from scratch."""
    u = _user(env)
    _require_module(u, "site_settings")
    rows = db.query("SELECT * FROM media_asset WHERE firm_id = ? ORDER BY uploaded_at DESC", (u["firm_id"],))
    return [dict(r, url="/media/" + r["filename"]) for r in rows]


@route("DELETE", r"/api/admin/media/(\d+)")
def admin_delete_media(env, m):
    """Deletes the file and its gallery row. Deliberately does not check
    whether the URL is still referenced by a blog post/service/team member/
    etc. — same posture as deleting a Download: the admin is trusted to
    know what they're removing, and a field still pointing at a deleted
    image just shows a broken image rather than silently blocking the
    delete with a cross-table reference scan."""
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT * FROM media_asset WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        return {"ok": True}
    db.execute("DELETE FROM media_asset WHERE id = ?", (m.group(1),))
    full = os.path.join(MEDIA_DIR, row["filename"])
    if os.path.isfile(full):
        os.remove(full)
    db.audit(u["id"], "delete", "media", m.group(1), row["original_filename"])
    return {"ok": True}


# ----------------------------------------------------------------- downloads
# Public document library — brochures, forms, circulars, whatever a firm
# wants a visitor (or a search engine / AI crawler) to find and download.
# Deliberately separate storage from MEDIA_DIR (images) and DOC_STORAGE_DIR
# (private client documents) — everything under DOWNLOADS_DIR is meant to
# be publicly served, never gated behind auth.

_ALLOWED_DOWNLOAD_TYPES = {
    "application/pdf": (".pdf", (b"%PDF",)),
    "application/msword": (".doc", (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",)),
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": (".docx", (b"PK\x03\x04",)),
    "application/vnd.ms-excel": (".xls", (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",)),
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": (".xlsx", (b"PK\x03\x04",)),
    "application/vnd.ms-powerpoint": (".ppt", (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",)),
    "application/vnd.openxmlformats-officedocument.presentationml.presentation": (".pptx", (b"PK\x03\x04",)),
}


@route("GET", r"/api/admin/downloads")
def admin_list_downloads(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    return db.query("SELECT * FROM download_file WHERE firm_id = ? ORDER BY uploaded_at DESC", (u["firm_id"],))


@route("POST", r"/api/admin/downloads")
def admin_upload_download(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    b = _json_body(env)
    title = (b.get("title") or "").strip()
    if not title:
        raise HttpError(400, "A title is required")
    content_type = b.get("content_type") or ""
    if content_type not in _ALLOWED_DOWNLOAD_TYPES:
        raise HttpError(400, "Only PDF, Word, Excel or PowerPoint files are allowed")
    try:
        blob = base64.b64decode(b.get("content_base64") or "")
    except Exception:
        raise HttpError(400, "The file content was not valid")
    if not blob:
        raise HttpError(400, "No file content received")
    if len(blob) > 20 * 1024 * 1024:
        raise HttpError(400, "Files must be 20MB or smaller")
    ext, magics = _ALLOWED_DOWNLOAD_TYPES[content_type]
    if not any(blob.startswith(sig) for sig in magics):
        raise HttpError(400, "That file doesn't look like a real " + ext[1:].upper() + " file")
    os.makedirs(DOWNLOADS_DIR, exist_ok=True)
    fname = secrets.token_hex(16) + ext
    with open(os.path.join(DOWNLOADS_DIR, fname), "wb") as f:
        f.write(blob)
    slug = _slugify(b.get("slug") or title)
    did = _execute_or_slug_conflict(
        "INSERT INTO download_file (firm_id, slug, title, description, file_path, original_filename,"
        " mime_type, file_size, is_published, uploaded_by) VALUES (?,?,?,?,?,?,?,?,?,?)",
        (u["firm_id"], slug, title, (b.get("description") or "").strip(), fname, b.get("filename") or fname,
         content_type, len(blob), 1 if b.get("is_published") else 0, u["id"]))
    db.audit(u["id"], "create", "download_file", did, title)
    return db.query("SELECT * FROM download_file WHERE id = ?", (did,), one=True)


@route("PUT", r"/api/admin/downloads/(\d+)")
def admin_update_download(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT * FROM download_file WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That file no longer exists")
    b = _json_body(env)
    # Partial update — the quick Publish/Unpublish button sends only
    # is_published; a blind full-row overwrite here would blank the title,
    # description and slug back to empty on every toggle click (title/slug
    # aren't NOT NULL, so this failed silently instead of erroring).
    title = (b.get("title") or "").strip() if "title" in b else row["title"]
    slug = _slugify(b.get("slug") or title) if ("slug" in b or "title" in b) else row["slug"]
    description = (b.get("description") or "").strip() if "description" in b else row["description"]
    is_published = (1 if b.get("is_published") else 0) if "is_published" in b else row["is_published"]
    _execute_or_slug_conflict(
        "UPDATE download_file SET slug=?, title=?, description=?, is_published=? WHERE id=?",
        (slug, title, description, is_published, m.group(1)))
    db.audit(u["id"], "update", "download_file", m.group(1))
    return db.query("SELECT * FROM download_file WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/downloads/(\d+)")
def admin_delete_download(env, m):
    u = _user(env)
    _require_module(u, "site_settings")
    row = db.query("SELECT * FROM download_file WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        return {"ok": True}
    db.execute("DELETE FROM download_file WHERE id = ?", (m.group(1),))
    full = os.path.join(DOWNLOADS_DIR, row["file_path"])
    if os.path.isfile(full):
        os.remove(full)
    db.audit(u["id"], "delete", "download_file", m.group(1), row["title"])
    return {"ok": True}


@route("GET", r"/api/admin/blog-categories")
def admin_list_blog_categories(env, m):
    u = _user(env)
    _require_platform_admin(u)
    return db.query("SELECT * FROM blog_category ORDER BY name")


@route("POST", r"/api/admin/blog-categories")
def admin_create_blog_category(env, m):
    u = _user(env)
    # blog_post/blog_category have no firm_id column at all — the blog is
    # genuinely CA-Automator's own single shared resource, not something a
    # tenant firm has an isolated copy of (unlike site_settings' services/
    # testimonials/etc, which each firm's writes DO safely scope to
    # u["firm_id"]). A weaker _require_module(blog_admin) check here would
    # let ANY firm granted that module edit or delete every other firm's
    # (and CA-Automator's own) posts — so this is platform-admin-only.
    _require_platform_admin(u)
    b = _json_body(env)
    if not b.get("name"):
        raise HttpError(400, "A name is required")
    cid = _execute_or_slug_conflict("INSERT INTO blog_category (slug, name, intro) VALUES (?,?,?)",
                     (_slugify(b.get("slug") or b["name"]), b["name"], b.get("intro")))
    db.audit(u["id"], "create", "blog_category", cid, b["name"])
    return db.query("SELECT * FROM blog_category WHERE id = ?", (cid,), one=True)


@route("PUT", r"/api/admin/blog-categories/(\d+)")
def admin_update_blog_category(env, m):
    u = _user(env)
    # blog_post/blog_category have no firm_id column at all — the blog is
    # genuinely CA-Automator's own single shared resource, not something a
    # tenant firm has an isolated copy of (unlike site_settings' services/
    # testimonials/etc, which each firm's writes DO safely scope to
    # u["firm_id"]). A weaker _require_module(blog_admin) check here would
    # let ANY firm granted that module edit or delete every other firm's
    # (and CA-Automator's own) posts — so this is platform-admin-only.
    _require_platform_admin(u)
    b = _json_body(env)
    _execute_or_slug_conflict("UPDATE blog_category SET slug = ?, name = ?, intro = ? WHERE id = ?",
              (_slugify(b.get("slug") or b.get("name")), b.get("name"), b.get("intro"), m.group(1)))
    db.audit(u["id"], "update", "blog_category", m.group(1))
    return db.query("SELECT * FROM blog_category WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/blog-categories/(\d+)")
def admin_delete_blog_category(env, m):
    u = _user(env)
    # blog_post/blog_category have no firm_id column at all — the blog is
    # genuinely CA-Automator's own single shared resource, not something a
    # tenant firm has an isolated copy of (unlike site_settings' services/
    # testimonials/etc, which each firm's writes DO safely scope to
    # u["firm_id"]). A weaker _require_module(blog_admin) check here would
    # let ANY firm granted that module edit or delete every other firm's
    # (and CA-Automator's own) posts — so this is platform-admin-only.
    _require_platform_admin(u)
    db.execute("UPDATE blog_post SET category_id = NULL WHERE category_id = ?", (m.group(1),))
    db.execute("DELETE FROM blog_category WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "blog_category", m.group(1))
    return {"ok": True}


@route("GET", r"/api/admin/blog")
def admin_list_blog(env, m):
    u = _user(env)
    _require_platform_admin(u)
    return db.query(
        "SELECT p.id, p.slug, p.title, p.status, p.published_at, p.updated_at, p.views,"
        " c.name category_name FROM blog_post p LEFT JOIN blog_category c ON c.id = p.category_id"
        " ORDER BY p.updated_at DESC")


@route("GET", r"/api/admin/blog/(\d+)")
def admin_get_blog_post(env, m):
    u = _user(env)
    _require_platform_admin(u)
    post = db.query("SELECT * FROM blog_post WHERE id = ?", (m.group(1),), one=True)
    if not post:
        raise HttpError(404, "That article no longer exists")
    post = dict(post)
    post["faqs"] = db.query("SELECT * FROM blog_faq WHERE post_id = ? ORDER BY sort_order", (m.group(1),))
    return post


_BLOG_FIELDS = ("slug", "title", "h1", "meta_description", "focus_keyword", "secondary_keywords",
               "category_id", "body_md", "canonical_url", "og_image_path", "schema_type", "review_due_on")


@route("POST", r"/api/admin/blog")
def admin_create_blog_post(env, m):
    u = _user(env)
    # blog_post/blog_category have no firm_id column at all — the blog is
    # genuinely CA-Automator's own single shared resource, not something a
    # tenant firm has an isolated copy of (unlike site_settings' services/
    # testimonials/etc, which each firm's writes DO safely scope to
    # u["firm_id"]). A weaker _require_module(blog_admin) check here would
    # let ANY firm granted that module edit or delete every other firm's
    # (and CA-Automator's own) posts — so this is platform-admin-only.
    _require_platform_admin(u)
    b = _json_body(env)
    if not b.get("title") or not b.get("body_md"):
        raise HttpError(400, "Title and body are required")
    words = len((b.get("body_md") or "").split())
    pid = _execute_or_slug_conflict(
        "INSERT INTO blog_post (slug, title, h1, meta_description, focus_keyword, secondary_keywords,"
        " category_id, body_md, reading_minutes, author_id, status, canonical_url, og_image_path,"
        " schema_type, review_due_on) VALUES (?,?,?,?,?,?,?,?,?,?, 'draft', ?,?,?,?)",
        (_slugify(b.get("slug") or b["title"]), b["title"], b.get("h1"), b.get("meta_description"), b.get("focus_keyword"),
         b.get("secondary_keywords"), b.get("category_id") or None, b["body_md"], max(1, words // 220),
         u["id"], b.get("canonical_url"), b.get("og_image_path"), b.get("schema_type") or "Article",
         b.get("review_due_on")))
    db.audit(u["id"], "create", "blog_post", pid, b["title"])
    return db.query("SELECT * FROM blog_post WHERE id = ?", (pid,), one=True)


@route("PUT", r"/api/admin/blog/(\d+)")
def admin_update_blog_post(env, m):
    u = _user(env)
    # blog_post/blog_category have no firm_id column at all — the blog is
    # genuinely CA-Automator's own single shared resource, not something a
    # tenant firm has an isolated copy of (unlike site_settings' services/
    # testimonials/etc, which each firm's writes DO safely scope to
    # u["firm_id"]). A weaker _require_module(blog_admin) check here would
    # let ANY firm granted that module edit or delete every other firm's
    # (and CA-Automator's own) posts — so this is platform-admin-only.
    _require_platform_admin(u)
    row = db.query("SELECT status, published_at FROM blog_post WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That article no longer exists")
    b = _json_body(env)
    words = len((b.get("body_md") or "").split())
    status = b.get("status") or row["status"]
    if status not in ("draft", "scheduled", "published"):
        raise HttpError(400, "Unknown status")
    published_at = row["published_at"]
    if status == "published" and not published_at:
        published_at = db.now_iso()
    _execute_or_slug_conflict(
        "UPDATE blog_post SET slug=?, title=?, h1=?, meta_description=?, focus_keyword=?, secondary_keywords=?,"
        " category_id=?, body_md=?, reading_minutes=?, status=?, published_at=?, updated_at=datetime('now'),"
        " canonical_url=?, og_image_path=?, schema_type=?, review_due_on=? WHERE id=?",
        (_slugify(b.get("slug") or b.get("title")), b.get("title"), b.get("h1"), b.get("meta_description"), b.get("focus_keyword"),
         b.get("secondary_keywords"), b.get("category_id") or None, b.get("body_md"), max(1, words // 220),
         status, published_at, b.get("canonical_url"), b.get("og_image_path"), b.get("schema_type") or "Article",
         b.get("review_due_on"), m.group(1)))
    if "faqs" in b:
        db.execute("DELETE FROM blog_faq WHERE post_id = ?", (m.group(1),))
        for i, f in enumerate(b["faqs"] or []):
            if f.get("question") and f.get("answer"):
                db.execute("INSERT INTO blog_faq (post_id, question, answer, sort_order) VALUES (?,?,?,?)",
                          (m.group(1), f["question"], f["answer"], i))
    db.audit(u["id"], "update", "blog_post", m.group(1))
    return admin_get_blog_post(env, m)


@route("DELETE", r"/api/admin/blog/(\d+)")
def admin_delete_blog_post(env, m):
    u = _user(env)
    # blog_post/blog_category have no firm_id column at all — the blog is
    # genuinely CA-Automator's own single shared resource, not something a
    # tenant firm has an isolated copy of (unlike site_settings' services/
    # testimonials/etc, which each firm's writes DO safely scope to
    # u["firm_id"]). A weaker _require_module(blog_admin) check here would
    # let ANY firm granted that module edit or delete every other firm's
    # (and CA-Automator's own) posts — so this is platform-admin-only.
    _require_platform_admin(u)
    db.execute("DELETE FROM blog_post WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "blog_post", m.group(1))
    return {"ok": True}


# ------------------------------------------------------------ careers / recruitment
# job_posting is publicly listable/viewable once published; job_application
# moves through a fixed set of stages (see schema.sql), each change writing
# an application_stage_history row and — via _CAREER_STAGE_TEMPLATE — an
# admin-editable email to the applicant. Stage order is a strong convention,
# not a rigid state machine: a recruiter can move an application to any
# stage (including straight to 'rejected' from 'new'), matching how hiring
# actually happens rather than forcing every application through every step.

_CAREER_STAGES = ("new", "screening", "under_review", "shortlisted", "interview", "selected", "rejected")
_CAREER_STAGE_TEMPLATE = {
    "screening": "career_screening", "under_review": "career_under_review",
    "shortlisted": "career_shortlisted", "interview": "career_interview",
    "selected": "career_selected", "rejected": "career_rejected",
}


@route("GET", r"/api/public/careers")
def public_list_careers(env, m):
    # Every tenant firm's vacancies share this one landing page (per the
    # firm's own explicit choice — unlike services/testimonials/blog, which
    # stay scoped to CA-Automator's own firm, see the "PUBLIC — main
    # landing page" note above public_list_services), so each listing must
    # name which firm is hiring rather than reading as CA-Automator's own.
    qs = parse_qs(env.get("QUERY_STRING", ""))
    limit = min(int((qs.get("limit") or ["20"])[0]), 100)
    return db.query(
        "SELECT j.slug, j.title, j.department, j.location, j.employment_type, j.deadline, f.name firm_name"
        " FROM job_posting j JOIN firm f ON f.id = j.firm_id"
        " WHERE j.is_published = 1 AND (j.deadline IS NULL OR j.deadline >= date('now'))"
        " ORDER BY j.created_at DESC LIMIT ?", (limit,))


@route("POST", r"/api/public/careers/([a-z0-9-]+)/apply")
def public_apply_career(env, m):
    job = db.query("SELECT * FROM job_posting WHERE slug = ? AND is_published = 1", (m.group(1),), one=True)
    if not job:
        raise HttpError(404, "That position is no longer open")
    if job["deadline"] and job["deadline"] < db.now_iso()[:10]:
        raise HttpError(409, "Applications for this position are now closed")
    b = _json_body(env)
    name = (b.get("name") or "").strip()
    email = (b.get("email") or "").strip()
    if not name or not email:
        raise HttpError(400, "Name and email are required")
    if not b.get("consent"):
        raise HttpError(400, "Please confirm you consent to us storing your application details")

    cv_path = cv_filename = None
    if b.get("cv_base64"):
        try:
            blob = base64.b64decode(b["cv_base64"])
        except Exception:
            raise HttpError(400, "The CV file content was not valid")
        if len(blob) > 8 * 1024 * 1024:
            raise HttpError(400, "The CV file is larger than 8MB")
        os.makedirs(CV_STORAGE_DIR, exist_ok=True)
        sha = hashlib.sha256(blob).hexdigest()
        cv_filename = b.get("cv_filename") or "cv"
        cv_path = os.path.join(CV_STORAGE_DIR, f"{sha[:16]}-{re.sub(r'[^A-Za-z0-9._-]', '_', cv_filename)}")
        with open(cv_path, "wb") as f:
            f.write(blob)

    aid = db.execute(
        "INSERT INTO job_application (job_id, name, email, phone, address, cv_path, cv_filename, cover_letter,"
        " education, experience, portfolio_url, consent, stage) VALUES (?,?,?,?,?,?,?,?,?,?,?,1,'new')",
        (job["id"], name, email, (b.get("phone") or "").strip(), b.get("address"), cv_path, cv_filename,
         b.get("cover_letter"), b.get("education"), b.get("experience"), b.get("portfolio_url")))
    db.execute("INSERT INTO application_stage_history (application_id, from_stage, to_stage, note)"
              " VALUES (?, NULL, 'new', 'Application submitted')", (aid,))

    # The confirmation email and the "new application" alert must both name
    # and reach the firm that actually posted this vacancy — not
    # CA-Automator's own default firm (_firm_name()'s hardcoded firm #1) and
    # not every tenant firm's admins platform-wide. An applicant to Firm B's
    # job has no relationship with Firm C, and Firm C's admin has no
    # business seeing Firm B's applicant.
    posting_firm = db.query("SELECT name FROM firm WHERE id = ?", (job["firm_id"],), one=True)
    tpl = notify.render_template("career_received", {"name": name, "position": job["title"],
                                                       "firm_name": (posting_firm["name"] if posting_firm else "Our team")})
    if tpl:
        notify.send_email(email, tpl[0], tpl[1], related_type="job_application", related_id=aid)
    for staff in db.query("SELECT id FROM app_user WHERE firm_id = ? AND is_super_admin = 1 AND is_active = 1",
                          (job["firm_id"],)):
        db.execute("INSERT INTO notification (user_id, kind, title, body, link) VALUES (?,?,?,?,?)",
                   (staff["id"], "job_application", f"New application: {job['title']}", name, "/#/admin"))
    return {"ok": True}


@route("GET", r"/api/admin/careers")
def admin_list_careers(env, m):
    u = _user(env)
    _require_module(u, "career_admin")
    return db.query(
        "SELECT j.*, (SELECT COUNT(*) FROM job_application a WHERE a.job_id = j.id) application_count"
        " FROM job_posting j WHERE j.firm_id = ? ORDER BY j.created_at DESC", (u["firm_id"],))


@route("POST", r"/api/admin/careers")
def admin_create_career(env, m):
    u = _user(env)
    _require_module(u, "career_admin")
    b = _json_body(env)
    if not b.get("title"):
        raise HttpError(400, "A title is required")
    jid = _execute_or_slug_conflict(
        "INSERT INTO job_posting (firm_id, slug, title, department, location, employment_type, salary_range,"
        " experience_required, education_required, skills, description_html, responsibilities_html,"
        " requirements_html, deadline, is_published) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (u["firm_id"], _slugify(b.get("slug") or b["title"]), b["title"], b.get("department"), b.get("location"),
         b.get("employment_type") or "full_time", b.get("salary_range"), b.get("experience_required"),
         b.get("education_required"), b.get("skills"), b.get("description_html"),
         b.get("responsibilities_html"), b.get("requirements_html"), b.get("deadline"),
         1 if b.get("is_published") else 0))
    db.audit(u["id"], "create", "job_posting", jid, b["title"])
    return db.query("SELECT * FROM job_posting WHERE id = ?", (jid,), one=True)


@route("PUT", r"/api/admin/careers/(\d+)")
def admin_update_career(env, m):
    u = _user(env)
    _require_module(u, "career_admin")
    row = db.query("SELECT id FROM job_posting WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not row:
        raise HttpError(404, "That posting no longer exists")
    b = _json_body(env)
    _execute_or_slug_conflict(
        "UPDATE job_posting SET slug=?, title=?, department=?, location=?, employment_type=?, salary_range=?,"
        " experience_required=?, education_required=?, skills=?, description_html=?, responsibilities_html=?,"
        " requirements_html=?, deadline=?, is_published=?, updated_at=datetime('now') WHERE id=?",
        (_slugify(b.get("slug") or b.get("title")), b.get("title"), b.get("department"), b.get("location"),
         b.get("employment_type") or "full_time", b.get("salary_range"), b.get("experience_required"),
         b.get("education_required"), b.get("skills"), b.get("description_html"),
         b.get("responsibilities_html"), b.get("requirements_html"), b.get("deadline"),
         1 if b.get("is_published") else 0, m.group(1)))
    db.audit(u["id"], "update", "job_posting", m.group(1))
    return db.query("SELECT * FROM job_posting WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/admin/careers/(\d+)")
def admin_delete_career(env, m):
    u = _user(env)
    _require_module(u, "career_admin")
    db.execute("DELETE FROM job_posting WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]))
    db.audit(u["id"], "delete", "job_posting", m.group(1))
    return {"ok": True}


@route("GET", r"/api/admin/applications")
def admin_list_applications(env, m):
    u = _user(env)
    _require_module(u, "career_admin")
    qs = parse_qs(env.get("QUERY_STRING", ""))
    sql = ("SELECT a.id, a.job_id, a.name, a.email, a.phone, a.stage, a.assigned_recruiter, a.created_at,"
          " j.title job_title FROM job_application a JOIN job_posting j ON j.id = a.job_id WHERE j.firm_id = ?")
    args = [u["firm_id"]]
    if qs.get("job_id"):
        sql += " AND a.job_id = ?"; args.append(qs["job_id"][0])
    if qs.get("stage"):
        sql += " AND a.stage = ?"; args.append(qs["stage"][0])
    return db.query(sql + " ORDER BY a.created_at DESC", args)


@route("GET", r"/api/admin/applications/(\d+)")
def admin_get_application(env, m):
    u = _user(env)
    _require_module(u, "career_admin")
    app_row = db.query(
        "SELECT a.*, j.title job_title, j.firm_id FROM job_application a JOIN job_posting j ON j.id = a.job_id"
        " WHERE a.id = ?", (m.group(1),), one=True)
    if not app_row or app_row["firm_id"] != u["firm_id"]:
        raise HttpError(404, "That application no longer exists")
    out = dict(app_row)
    out["history"] = db.query(
        "SELECT h.*, au.full_name changed_by_name FROM application_stage_history h"
        " LEFT JOIN app_user au ON au.id = h.changed_by WHERE h.application_id = ? ORDER BY h.created_at",
        (m.group(1),))
    return out


@route("PUT", r"/api/admin/applications/(\d+)")
def admin_update_application(env, m):
    u = _user(env)
    _require_module(u, "career_admin")
    row = db.query(
        "SELECT a.*, j.title job_title, j.firm_id FROM job_application a JOIN job_posting j ON j.id = a.job_id"
        " WHERE a.id = ?", (m.group(1),), one=True)
    if not row or row["firm_id"] != u["firm_id"]:
        raise HttpError(404, "That application no longer exists")
    b = _json_body(env)
    sets, args = [], []
    if "assigned_recruiter" in b:
        sets.append("assigned_recruiter = ?"); args.append(b["assigned_recruiter"])
    if "internal_notes" in b:
        sets.append("internal_notes = ?"); args.append(b["internal_notes"])
    new_stage = b.get("stage")
    if new_stage is not None:
        if new_stage not in _CAREER_STAGES:
            raise HttpError(400, "Unknown stage")
        sets.append("stage = ?"); args.append(new_stage)
    if sets:
        sets.append("updated_at = datetime('now')")
        db.execute(f"UPDATE job_application SET {', '.join(sets)} WHERE id = ?", args + [m.group(1)])
    if new_stage is not None and new_stage != row["stage"]:
        db.execute(
            "INSERT INTO application_stage_history (application_id, from_stage, to_stage, changed_by, note)"
            " VALUES (?,?,?,?,?)", (m.group(1), row["stage"], new_stage, u["id"], b.get("note")))
        tpl_key = _CAREER_STAGE_TEMPLATE.get(new_stage)
        if tpl_key:
            posting_firm = db.query("SELECT name FROM firm WHERE id = ?", (row["firm_id"],), one=True)
            tpl = notify.render_template(tpl_key, {"name": row["name"], "position": row["job_title"],
                                                    "firm_name": posting_firm["name"] if posting_firm else "Our team"})
            if tpl:
                notify.send_email(row["email"], tpl[0], tpl[1], related_type="job_application", related_id=m.group(1))
    db.audit(u["id"], "update", "job_application", m.group(1), new_stage or "details")
    return admin_get_application(env, m)


@route("GET", r"/api/admin/applications/(\d+)/cv")
def admin_download_cv(env, m):
    u = _user(env)
    _require_module(u, "career_admin")
    row = db.query(
        "SELECT a.cv_path, a.cv_filename, j.firm_id FROM job_application a JOIN job_posting j ON j.id = a.job_id"
        " WHERE a.id = ?", (m.group(1),), one=True)
    if not row or row["firm_id"] != u["firm_id"] or not row["cv_path"] or not os.path.exists(row["cv_path"]):
        raise HttpError(404, "No CV on file for this application")
    with open(row["cv_path"], "rb") as f:
        blob = f.read()
    return ("__raw__", blob, "application/octet-stream", row["cv_filename"] or "cv")


# -------------------------------------------------------------- client portal
# Admin (staff) side: inviting/managing a client's portal contacts. The
# portal side (login, bootstrap, own data) is further down, after the
# appointments section, next to the other genuinely-public routes.

@route("GET", r"/api/clients/(\d+)/portal-users")
def list_portal_users(env, m):
    u = _user(env)
    _assert_client_visible(u, int(m.group(1)))
    return db.query("SELECT id, client_id, name, email, is_active, created_at, last_login_at"
                    " FROM client_portal_user WHERE client_id = ? ORDER BY created_at", (m.group(1),))


@route("POST", r"/api/clients/(\d+)/portal-users")
def invite_portal_user(env, m):
    u = _user(env)
    _assert_client_visible(u, int(m.group(1)))
    b = _json_body(env)
    name = (b.get("name") or "").strip()
    email = (b.get("email") or "").strip().lower()
    if not name or not email:
        raise HttpError(400, "A name and email are required")
    if db.query("SELECT 1 FROM client_portal_user WHERE email = ?", (email,), one=True):
        raise HttpError(400, "That email already has portal access")
    client = db.query("SELECT name FROM client WHERE id = ?", (m.group(1),), one=True)
    pid = db.portal_create_user(m.group(1), name, email, secrets.token_urlsafe(32))
    token = db.portal_create_invite(pid)
    link = f"{_base_url(env)}/portal.html#/invite/{token}"
    notify.send_email(email, f"You've been given access to {client['name']}'s client portal",
                      f"Hi {name},\n\n{u['full_name']} has set up portal access for {client['name']}.\n"
                      f"Set your password to get started:\n{link}\n\nThis link expires in 7 days.",
                      related_type="client_portal_user", related_id=pid)
    db.audit(u["id"], "create", "client_portal_user", pid, f"{name} <{email}> for client #{m.group(1)}")
    return db.query("SELECT id, client_id, name, email, is_active, created_at FROM client_portal_user WHERE id = ?",
                    (pid,), one=True)


@route("PUT", r"/api/portal-users/(\d+)")
def update_portal_user(env, m):
    u = _user(env)
    row = db.query("SELECT * FROM client_portal_user WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That portal contact no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    if "is_active" in b:
        db.execute("UPDATE client_portal_user SET is_active = ? WHERE id = ?",
                  (1 if b["is_active"] else 0, m.group(1)))
        if not b["is_active"]:
            db.execute("UPDATE client_portal_session SET revoked_at = ? WHERE portal_user_id = ? AND revoked_at IS NULL",
                      (db.now_iso(), m.group(1)))
    db.audit(u["id"], "update", "client_portal_user", m.group(1))
    return {"ok": True}


@route("DELETE", r"/api/portal-users/(\d+)")
def delete_portal_user(env, m):
    u = _user(env)
    row = db.query("SELECT client_id FROM client_portal_user WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That portal contact no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM client_portal_user WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "client_portal_user", m.group(1))
    return {"ok": True}


# ---------------------------------------------------------- appointments
# A slot is only ever bookable if an admin deliberately opened it (no
# implicit "everything is available" default), booking a slot and marking it
# taken happen in one database transaction (see book_appointment below) so
# two visitors racing for the same slot can't both win it, and every booking
# is real, persisted data — nothing here is calculated on the fly. Firm is
# single-tenant per install, same posture as /api/leads, so the public
# routes don't need a firm parameter — they resolve it from the slot itself.

APPOINTMENT_METHODS = ("phone", "video", "in_person")
APPOINTMENT_METHOD_LABELS = {"phone": "Phone call", "video": "Video call", "in_person": "In-person / office visit"}
# Mirrors the five public service blocks on the landing page — kept as one
# server-side list so the booking form's dropdown can never drift out of
# sync with what the site actually advertises.
APPOINTMENT_SERVICES = ("fdi", "tax", "valuation", "advisory", "transfer_pricing", "other")
APPOINTMENT_SERVICE_LABELS = {
    "fdi": "FDI consultancy — entry structuring & approval routing",
    "tax": "Tax advisory — direct, VAT & cross-border",
    "valuation": "Business / share / asset valuation",
    "advisory": "Corporate & regulatory advisory",
    "transfer_pricing": "Transfer pricing advisory & compliance",
    "other": "Something else / not sure yet",
}


def _slot_out(row):
    d = dict(row)
    try:
        h1, m1 = (int(x) for x in d["start_time"].split(":"))
        h2, m2 = (int(x) for x in d["end_time"].split(":"))
        hours = ((h2 * 60 + m2) - (h1 * 60 + m1)) / 60
    except Exception:
        hours = 1.0
    d["duration_hours"] = round(hours, 2)
    d["price"] = round(hours * d["price_per_hour"], 2)
    return d


@route("GET", r"/api/admin/appointment-slots")
def admin_list_appointment_slots(env, m):
    u = _user(env)
    _require_admin(u)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    date_from = (qs.get("from") or [None])[0] or db.now_iso()[:10]
    date_to = (qs.get("to") or [None])[0] or (datetime.now(timezone.utc) + timedelta(days=60)).strftime("%Y-%m-%d")
    rows = db.query(
        "SELECT s.*, b.id booking_id, b.name booking_name, b.email booking_email, b.phone booking_phone,"
        " b.method booking_method, b.status booking_status FROM appointment_slot s"
        " LEFT JOIN appointment_booking b ON b.slot_id = s.id"
        " WHERE s.firm_id = ? AND s.date BETWEEN ? AND ? ORDER BY s.date, s.start_time",
        (u["firm_id"], date_from, date_to))
    return [_slot_out(r) for r in rows]


@route("POST", r"/api/admin/appointment-slots")
def admin_create_appointment_slot(env, m):
    u = _user(env)
    _require_admin(u)
    b = _json_body(env)
    if not b.get("date") or not b.get("start_time") or not b.get("end_time") or b.get("price_per_hour") is None:
        raise HttpError(400, "Date, start time, end time and price per hour are all required")
    if b["end_time"] <= b["start_time"]:
        raise HttpError(400, "End time must be after start time")
    try:
        sid = db.execute(
            "INSERT INTO appointment_slot (firm_id, date, start_time, end_time, price_per_hour, notes, created_by)"
            " VALUES (?,?,?,?,?,?,?)",
            (u["firm_id"], b["date"], b["start_time"], b["end_time"], float(b["price_per_hour"]), b.get("notes"), u["id"]))
    except db.IntegrityError:
        raise HttpError(400, "A slot already exists at this date and start time")
    db.audit(u["id"], "create", "appointment_slot", sid, f"{b['date']} {b['start_time']}")
    return _slot_out(db.query("SELECT * FROM appointment_slot WHERE id = ?", (sid,), one=True))


@route("POST", r"/api/admin/appointment-slots/bulk")
def admin_bulk_create_appointment_slots(env, m):
    """Generate slots across a date range in one go: every matching weekday,
    chopped into slot_minutes increments between daily_start and daily_end.
    Existing slots at the same date/time are silently skipped (INSERT OR
    IGNORE), so running this again to add more days never duplicates or
    disturbs slots that may already be booked."""
    u = _user(env)
    _require_admin(u)
    b = _json_body(env)
    required = ("date_from", "date_to", "daily_start", "daily_end", "slot_minutes", "price_per_hour")
    if any(b.get(f) in (None, "") for f in required):
        raise HttpError(400, "date_from, date_to, daily_start, daily_end, slot_minutes and price_per_hour are all required")
    weekdays = set(b.get("weekdays") or [0, 1, 2, 3, 4, 5, 6])
    try:
        d0 = datetime.strptime(b["date_from"], "%Y-%m-%d")
        d1 = datetime.strptime(b["date_to"], "%Y-%m-%d")
    except ValueError:
        raise HttpError(400, "date_from/date_to must be YYYY-MM-DD")
    if (d1 - d0).days > 180:
        raise HttpError(400, "Generate at most 180 days at a time")
    slot_minutes = int(b["slot_minutes"])
    if slot_minutes < 5:
        raise HttpError(400, "slot_minutes must be at least 5")
    day_start_min = int(b["daily_start"].split(":")[0]) * 60 + int(b["daily_start"].split(":")[1])
    day_end_min = int(b["daily_end"].split(":")[0]) * 60 + int(b["daily_end"].split(":")[1])
    if day_end_min <= day_start_min:
        raise HttpError(400, "daily_end must be after daily_start")

    created = 0
    with db.conn() as c:
        day = d0
        while day <= d1:
            # Python weekday(): Mon=0..Sun=6. Accept weekdays as 0=Sun..6=Sat
            # (the frontend's own convention, matching Date.getDay()) so
            # translate once here rather than asking the caller to know
            # Python's convention.
            py_to_js_dow = (day.weekday() + 1) % 7
            if py_to_js_dow in weekdays:
                t = day_start_min
                while t + slot_minutes <= day_end_min:
                    st = f"{t // 60:02d}:{t % 60:02d}"
                    et = f"{(t + slot_minutes) // 60:02d}:{(t + slot_minutes) % 60:02d}"
                    cur = c.execute(
                        "INSERT OR IGNORE INTO appointment_slot (firm_id, date, start_time, end_time, price_per_hour, notes, created_by)"
                        " VALUES (?,?,?,?,?,?,?)",
                        (u["firm_id"], day.strftime("%Y-%m-%d"), st, et, float(b["price_per_hour"]), b.get("notes"), u["id"]))
                    if cur.rowcount:
                        created += 1
                    t += slot_minutes
            day += timedelta(days=1)
    db.audit(u["id"], "create", "appointment_slot", None, f"bulk: {created} slots {b['date_from']}..{b['date_to']}")
    return {"created": created}


@route("DELETE", r"/api/admin/appointment-slots/(\d+)")
def admin_delete_appointment_slot(env, m):
    u = _user(env)
    _require_admin(u)
    row = db.query("SELECT * FROM appointment_slot WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That slot no longer exists")
    if row["status"] == "booked":
        raise HttpError(400, "This slot has a booking — cancel the booking first")
    db.execute("DELETE FROM appointment_slot WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "appointment_slot", int(m.group(1)))
    return {"ok": True}


@route("PUT", r"/api/admin/appointment-slots/(\d+)")
def admin_update_appointment_slot(env, m):
    u = _user(env)
    _require_admin(u)
    row = db.query("SELECT * FROM appointment_slot WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That slot no longer exists")
    if row["status"] == "booked":
        raise HttpError(400, "This slot has a booking — cancel the booking first")
    b = _json_body(env)
    status = b.get("status")
    if status not in (None, "open", "blocked"):
        raise HttpError(400, "Unknown status")
    if status:
        db.execute("UPDATE appointment_slot SET status = ? WHERE id = ?", (status, m.group(1)))
    return _slot_out(db.query("SELECT * FROM appointment_slot WHERE id = ?", (m.group(1),), one=True))


@route("GET", r"/api/admin/appointment-bookings")
def admin_list_appointment_bookings(env, m):
    u = _user(env)
    _require_admin(u)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    status = (qs.get("status") or [None])[0]
    sql = ("SELECT b.*, s.date, s.start_time, s.end_time FROM appointment_booking b"
           " JOIN appointment_slot s ON s.id = b.slot_id WHERE b.firm_id = ?")
    args = [u["firm_id"]]
    if status:
        sql += " AND b.status = ?"; args.append(status)
    return db.query(sql + " ORDER BY s.date DESC, s.start_time DESC", args)


@route("PUT", r"/api/admin/appointment-bookings/(\d+)")
def admin_update_appointment_booking(env, m):
    u = _user(env)
    _require_admin(u)
    row = db.query("SELECT * FROM appointment_booking WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That booking no longer exists")
    b = _json_body(env)
    status = b.get("status")
    if status not in ("pending", "confirmed", "cancelled"):
        raise HttpError(400, "Unknown status")
    with db.conn() as c:
        c.execute("UPDATE appointment_booking SET status = ?, cancel_reason = ? WHERE id = ?",
                  (status, b.get("cancel_reason") if status == "cancelled" else None, m.group(1)))
        if status == "cancelled":
            c.execute("UPDATE appointment_slot SET status = 'open' WHERE id = ?", (row["slot_id"],))
    db.audit(u["id"], "update", "appointment_booking", m.group(1), status)
    return db.query(
        "SELECT b.*, s.date, s.start_time, s.end_time FROM appointment_booking b"
        " JOIN appointment_slot s ON s.id = b.slot_id WHERE b.id = ?", (m.group(1),), one=True)


# ---- public (no session) — the booking widget on the landing page

@route("GET", r"/api/public/appointment-slots")
def public_list_appointment_slots(env, m):
    qs = parse_qs(env.get("QUERY_STRING", ""))
    now = db.now_iso()
    today = now[:10]
    now_time = now[11:16]
    date_from = (qs.get("from") or [None])[0] or today
    date_to = (qs.get("to") or [None])[0] or (datetime.now(timezone.utc) + timedelta(days=60)).strftime("%Y-%m-%d")
    if date_from < today:
        date_from = today
    rows = db.query(
        "SELECT id, date, start_time, end_time, price_per_hour FROM appointment_slot"
        " WHERE status = 'open' AND date BETWEEN ? AND ?"
        # A slot dated today whose start time has already gone by is
        # excluded here too, not just at booking time — the list should
        # never have offered it as a choice in the first place.
        " AND (date > ? OR start_time > ?)"
        " ORDER BY date, start_time",
        (date_from, date_to, today, now_time))
    # The real service catalogue (Admin > Website > Services) wins once a
    # firm has published anything bookable there; the fixed five-item list
    # below only exists so booking still works for a firm that hasn't set up
    # Services yet — it's a fallback, not the source of truth.
    real_services = db.query(
        "SELECT id, name FROM service WHERE is_published = 1 AND requires_appointment = 1 ORDER BY sort_order, name")
    services = [{"value": "svc:" + str(s["id"]), "label": s["name"]} for s in real_services] or \
        [{"value": k, "label": APPOINTMENT_SERVICE_LABELS[k]} for k in APPOINTMENT_SERVICES]
    return {"methods": [{"value": k, "label": v} for k, v in APPOINTMENT_METHOD_LABELS.items()],
            "services": services, "slots": [_slot_out(r) for r in rows]}


@route("POST", r"/api/public/appointments")
def public_book_appointment(env, m):
    b = _json_body(env)
    slot_id = b.get("slot_id")
    name = (b.get("name") or "").strip()
    email = (b.get("email") or "").strip()
    method = b.get("method")
    service = b.get("service")
    if not slot_id or not name or not email:
        raise HttpError(400, "Slot, name and email are required")
    if method not in APPOINTMENT_METHODS:
        raise HttpError(400, "Choose how you'd like to meet — phone, video or in person")
    service_id = None
    if isinstance(service, str) and service.startswith("svc:"):
        service_row = db.query("SELECT id, name FROM service WHERE id = ? AND is_published = 1",
                               (service[4:],), one=True)
        if not service_row:
            raise HttpError(400, "Choose which service this is about")
        service_id, service = service_row["id"], service_row["name"]
    elif service not in APPOINTMENT_SERVICES:
        raise HttpError(400, "Choose which service this is about")
    slot = db.query("SELECT * FROM appointment_slot WHERE id = ?", (slot_id,), one=True)
    if not slot:
        raise HttpError(404, "That slot no longer exists — refresh and pick another time")
    # The list endpoint only filters by date (not time), and a stale page or
    # a direct API call can point at a slot from a previous session, so the
    # past/future check has to be enforced again here — this is the actual
    # boundary, not the listing filter, which is convenience only.
    slot_datetime = f"{slot['date']} {slot['start_time']}"
    if slot_datetime <= db.now_iso()[:16]:
        raise HttpError(409, "That slot's date and time have already passed — refresh and pick another time")

    # Booked from inside the client portal, if a valid portal session sent
    # the request — optional, since this route is also reachable by an
    # anonymous visitor with no client relationship yet at all.
    portal_client_id = None
    portal_auth = env.get("HTTP_AUTHORIZATION", "")
    if portal_auth.lower().startswith("bearer "):
        pu = db.portal_user_for_token(portal_auth[7:].strip())
        if pu:
            portal_client_id = pu["client_id"]

    with db.conn() as c:
        cur = c.execute("UPDATE appointment_slot SET status = 'booked' WHERE id = ? AND status = 'open'", (slot_id,))
        if cur.rowcount == 0:
            raise HttpError(409, "That slot was just taken by someone else — please choose another time")
        priced = _slot_out(slot)
        bid = c.execute(
            "INSERT INTO appointment_booking (slot_id, firm_id, name, email, phone, company, method, service,"
            " service_id, client_id, message, price, status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?, 'pending')",
            (slot_id, slot["firm_id"], name, email, (b.get("phone") or "").strip(), (b.get("company") or "").strip(),
             method, service, service_id, portal_client_id, (b.get("message") or "").strip(), priced["price"])).lastrowid
    return {"ok": True, "booking_id": bid, "date": slot["date"], "start_time": slot["start_time"],
            "end_time": slot["end_time"], "price": priced["price"],
            "note": "Your request has been sent — you'll receive confirmation once the firm accepts it."}


# ---------------------------------------------------- practice management
# The eighteen requested automators map onto five new tables plus what this
# app already has (deadline, invoice, client_followup, time_entry) — see the
# comment above pm_checklist_item in schema.sql for the reasoning. Every
# route here is gated by _require_module(u, "practice_mgmt") + client
# visibility, same as every other client-scoped module.

PM_MODULE = "practice_mgmt"

PM_CHECKLIST_TEMPLATES = {
    "onboarding": [
        "Client information sheet completed", "PAN/VAT registration certificate obtained",
        "Certificate of incorporation / registration obtained", "MOA/AOA or partnership deed obtained",
        "Prior year financial statements obtained", "Prior auditor's NOC / communication with predecessor",
        "Engagement letter signed and on file", "Fee agreed and documented",
        "Client added to compliance calendar", "Client onboarded in billing system",
    ],
    "document_request": [
        "Trial balance / financial statements", "Bank statements for the period", "Fixed asset register",
        "Tax returns filed for the period", "Board / AGM minutes", "Related-party transaction listing",
        "Statutory registers", "Previous year's audit file / working papers",
    ],
    "closure": [
        "All fieldwork completed and reviewed", "Outstanding queries cleared",
        "Working papers finalised and referenced", "Partner / manager review completed",
        "Final deliverable issued to client", "Fees fully billed", "Fees collected / outstanding balance noted",
        "File archived", "Lessons learned / points forward noted",
    ],
}


@route("GET", r"/api/pm/clients/(\d+)/checklist")
def pm_list_checklist(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    stage = (qs.get("stage") or [None])[0]
    sql = "SELECT * FROM pm_checklist_item WHERE client_id = ?"
    args = [client_id]
    if stage:
        sql += " AND stage = ?"; args.append(stage)
    return db.query(sql + " ORDER BY stage, id", args)


@route("POST", r"/api/pm/clients/(\d+)/checklist")
def pm_create_checklist_item(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if b.get("stage") not in ("onboarding", "document_request", "closure"):
        raise HttpError(400, "Unknown stage")
    if not (b.get("label") or "").strip():
        raise HttpError(400, "A checklist item needs a label")
    cid = db.execute(
        "INSERT INTO pm_checklist_item (client_id, engagement_id, stage, label, due_date, notes, created_by)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b.get("engagement_id"), b["stage"], b["label"].strip(), b.get("due_date"), b.get("notes"), u["id"]))
    return db.query("SELECT * FROM pm_checklist_item WHERE id = ?", (cid,), one=True)


@route("POST", r"/api/pm/clients/(\d+)/checklist/seed")
def pm_seed_checklist(env, m):
    """Adds the standard template items for a stage, skipping any label
    already present so this is safe to call again without duplicating."""
    u = _user(env)
    _require_module(u, PM_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    stage = b.get("stage")
    if stage not in PM_CHECKLIST_TEMPLATES:
        raise HttpError(400, "Unknown stage")
    existing = {r["label"] for r in db.query(
        "SELECT label FROM pm_checklist_item WHERE client_id = ? AND stage = ?", (client_id, stage))}
    added = 0
    for label in PM_CHECKLIST_TEMPLATES[stage]:
        if label in existing:
            continue
        db.execute("INSERT INTO pm_checklist_item (client_id, engagement_id, stage, label, created_by) VALUES (?,?,?,?,?)",
                   (client_id, b.get("engagement_id"), stage, label, u["id"]))
        added += 1
    return {"added": added}


@route("PUT", r"/api/pm/checklist/(\d+)")
def pm_update_checklist_item(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    row = db.query("SELECT * FROM pm_checklist_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That checklist item no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["label", "status", "due_date", "notes"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if sets:
        db.execute(f"UPDATE pm_checklist_item SET {', '.join(sets)}, updated_at = ? WHERE id = ?",
                   [b[f] for f in fields if f in b] + [db.now_iso(), m.group(1)])
    return db.query("SELECT * FROM pm_checklist_item WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/pm/checklist/(\d+)")
def pm_delete_checklist_item(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    row = db.query("SELECT client_id FROM pm_checklist_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That checklist item no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM pm_checklist_item WHERE id = ?", (m.group(1),))
    return {"ok": True}


# ---- client & engagement acceptance (KYC + conflict check + risk assessment)

@route("GET", r"/api/pm/clients/(\d+)/acceptance")
def pm_get_acceptance(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    row = db.query("SELECT * FROM pm_acceptance WHERE client_id = ? ORDER BY id DESC LIMIT 1", (client_id,), one=True)
    if row:
        row["conflict_matches"] = json.loads(row["conflict_matches_json"]) if row["conflict_matches_json"] else []
        row["risk_factors"] = json.loads(row["risk_factors_json"]) if row["risk_factors_json"] else {}
    return row or {}


@route("PUT", r"/api/pm/clients/(\d+)/acceptance")
def pm_put_acceptance(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    existing = db.query("SELECT id FROM pm_acceptance WHERE client_id = ? ORDER BY id DESC LIMIT 1", (client_id,), one=True)
    fields = {
        "engagement_id": b.get("engagement_id"),
        "kyc_identity_verified": 1 if b.get("kyc_identity_verified") else 0,
        "kyc_beneficial_ownership": 1 if b.get("kyc_beneficial_ownership") else 0,
        "kyc_source_of_funds": 1 if b.get("kyc_source_of_funds") else 0,
        "kyc_pep_check": 1 if b.get("kyc_pep_check") else 0,
        "kyc_notes": b.get("kyc_notes"),
        "conflict_search_names": b.get("conflict_search_names"),
        "conflict_matches_json": json.dumps(b.get("conflict_matches") or []),
        "conflict_decision": b.get("conflict_decision"),
        "conflict_notes": b.get("conflict_notes"),
        "risk_factors_json": json.dumps(b.get("risk_factors") or {}),
        "risk_level": b.get("risk_level"),
        "risk_notes": b.get("risk_notes"),
        "acceptance_decision": b.get("acceptance_decision") or "pending",
        "acceptance_conditions": b.get("acceptance_conditions"),
    }
    if fields["acceptance_decision"] != "pending":
        fields["decided_by"] = u["id"]
        fields["decided_at"] = db.now_iso()
    cols = list(fields.keys())
    if existing:
        db.execute(f"UPDATE pm_acceptance SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [fields[c] for c in cols] + [existing["id"]])
        aid = existing["id"]
    else:
        aid = db.execute(
            f"INSERT INTO pm_acceptance (client_id, {', '.join(cols)}) VALUES (?, {', '.join('?' for _ in cols)})",
            [client_id] + [fields[c] for c in cols])
    db.audit(u["id"], "update", "pm_acceptance", aid, fields["acceptance_decision"])
    return pm_get_acceptance(env, m)


@route("GET", r"/api/pm/conflict-search")
def pm_conflict_search(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    q = ((qs.get("q") or [""])[0] or "").strip()
    if len(q) < 2:
        return []
    exclude = (qs.get("exclude_client_id") or [None])[0]
    sql = "SELECT id, name, code, entity_type FROM client WHERE firm_id = ? AND name LIKE ?"
    args = [u["firm_id"], f"%{q}%"]
    if exclude:
        sql += " AND id != ?"; args.append(exclude)
    return db.query(sql + " LIMIT 20", args)


# ---- proposals, fee quotations & engagement letters

@route("GET", r"/api/pm/clients/(\d+)/proposals")
def pm_list_proposals(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    ptype = (qs.get("type") or [None])[0]
    sql = "SELECT * FROM pm_proposal WHERE client_id = ?"
    args = [client_id]
    if ptype:
        sql += " AND type = ?"; args.append(ptype)
    return db.query(sql + " ORDER BY id DESC", args)


@route("POST", r"/api/pm/clients/(\d+)/proposals")
def pm_create_proposal(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if b.get("type") not in ("proposal", "fee_quote", "engagement_letter"):
        raise HttpError(400, "Unknown type")
    pid = db.execute(
        "INSERT INTO pm_proposal (client_id, engagement_id, type, title, scope_of_services, fee_structure,"
        " fee_amount, validity_date, status, notes, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
        (client_id, b.get("engagement_id"), b["type"], b.get("title"), b.get("scope_of_services"),
         b.get("fee_structure"), b.get("fee_amount"), b.get("validity_date"), b.get("status") or "draft",
         b.get("notes"), u["id"]))
    return db.query("SELECT * FROM pm_proposal WHERE id = ?", (pid,), one=True)


@route("PUT", r"/api/pm/proposals/(\d+)")
def pm_update_proposal(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    row = db.query("SELECT client_id FROM pm_proposal WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That record no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["title", "scope_of_services", "fee_structure", "fee_amount", "validity_date", "status", "notes"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if sets:
        db.execute(f"UPDATE pm_proposal SET {', '.join(sets)} WHERE id = ?",
                   [b[f] for f in fields if f in b] + [m.group(1)])
    return db.query("SELECT * FROM pm_proposal WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/pm/proposals/(\d+)")
def pm_delete_proposal(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    row = db.query("SELECT client_id FROM pm_proposal WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That record no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM pm_proposal WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("POST", r"/api/pm/proposals/(\d+)/document")
def pm_proposal_document(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    row = db.query("SELECT * FROM pm_proposal WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That record no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fmt = b.get("format", "docx")
    client = db.query("SELECT * FROM client WHERE id = ?", (row["client_id"],), one=True)
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": client["name"] if client else "", "fy": "", "unverified": [],
            "title": reports.TITLES.get("practice_mgmt", "Practice management")}
    result = dict(row)
    result["client_name"] = client["name"] if client else ""
    result["firm_name"] = firm.get("name", "")
    try:
        path, filename = reports.build("practice_mgmt", f"{fmt}_{row['type']}", result, meta, REPORT_DIR)
    except ValueError as e:
        raise HttpError(400, str(e))
    rid = db.execute(
        "INSERT INTO report (module_key, format, filename, storage_path, params, status, generated_by, byte_size)"
        " VALUES (?,?,?,?,?,?,?,?)",
        ("practice_mgmt", fmt, filename, path, json.dumps({"proposal_id": row["id"], "type": row["type"]}),
         "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "report", rid, f"practice_mgmt.{row['type']}.{fmt}")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download", "byte_size": os.path.getsize(path)}


# ---- assignments (staff allocation)

@route("GET", r"/api/pm/clients/(\d+)/assignments")
def pm_list_client_assignments(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query(
        "SELECT a.*, au.full_name assigned_to_name FROM pm_assignment a LEFT JOIN app_user au ON au.id = a.assigned_to"
        " WHERE a.client_id = ? ORDER BY a.status, a.due_date IS NULL, a.due_date", (client_id,))


@route("GET", r"/api/pm/assignments")
def pm_list_all_assignments(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    assigned_to = (qs.get("assigned_to") or [None])[0]
    status = (qs.get("status") or [None])[0]
    visible = None if u["is_super_admin"] else db.visible_client_ids(u)
    sql = ("SELECT a.*, au.full_name assigned_to_name, c.name client_name FROM pm_assignment a"
           " LEFT JOIN app_user au ON au.id = a.assigned_to JOIN client c ON c.id = a.client_id WHERE c.firm_id = ?")
    args = [u["firm_id"]]
    if visible is not None:
        if not visible:
            return []
        sql += f" AND a.client_id IN ({','.join('?' * len(visible))})"; args += list(visible)
    if assigned_to:
        sql += " AND a.assigned_to = ?"; args.append(assigned_to)
    if status:
        sql += " AND a.status = ?"; args.append(status)
    return db.query(sql + " ORDER BY a.status, a.due_date IS NULL, a.due_date", args)


@route("POST", r"/api/pm/clients/(\d+)/assignments")
def pm_create_assignment(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("task") or "").strip():
        raise HttpError(400, "An assignment needs a task description")
    aid = db.execute(
        "INSERT INTO pm_assignment (client_id, engagement_id, task, assigned_to, due_date, priority, notes, created_by)"
        " VALUES (?,?,?,?,?,?,?,?)",
        (client_id, b.get("engagement_id"), b["task"].strip(), b.get("assigned_to"), b.get("due_date"),
         b.get("priority") or "normal", b.get("notes"), u["id"]))
    return db.query("SELECT * FROM pm_assignment WHERE id = ?", (aid,), one=True)


@route("PUT", r"/api/pm/assignments/(\d+)")
def pm_update_assignment(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    row = db.query("SELECT client_id FROM pm_assignment WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That assignment no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["task", "assigned_to", "due_date", "priority", "status", "notes"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if sets:
        db.execute(f"UPDATE pm_assignment SET {', '.join(sets)} WHERE id = ?",
                   [b[f] for f in fields if f in b] + [m.group(1)])
    return db.query("SELECT * FROM pm_assignment WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/pm/assignments/(\d+)")
def pm_delete_assignment(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    row = db.query("SELECT client_id FROM pm_assignment WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That assignment no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM pm_assignment WHERE id = ?", (m.group(1),))
    return {"ok": True}


# ---- time budgeting — budget stored here, actual hours read live from the
# existing time_entry table so there is exactly one source of truth for
# hours actually worked.

@route("GET", r"/api/pm/engagements/(\d+)/time-budget")
def pm_get_time_budget(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    eng = db.query("SELECT client_id FROM engagement WHERE id = ?", (m.group(1),), one=True)
    if not eng:
        raise HttpError(404, "That engagement no longer exists")
    _assert_client_visible(u, eng["client_id"])
    budgets = db.query(
        "SELECT b.*, au.full_name staff_name FROM pm_time_budget b LEFT JOIN app_user au ON au.id = b.staff_id"
        " WHERE b.engagement_id = ? ORDER BY b.id", (m.group(1),))
    actual_total = db.query("SELECT COALESCE(SUM(hours), 0) h FROM time_entry WHERE engagement_id = ?",
                            (m.group(1),), one=True)["h"]
    actual_by_staff = db.query(
        "SELECT user_id staff_id, COALESCE(SUM(hours), 0) hours FROM time_entry WHERE engagement_id = ? GROUP BY user_id",
        (m.group(1),))
    actual_map = {r["staff_id"]: r["hours"] for r in actual_by_staff}
    for bud in budgets:
        bud["actual_hours"] = actual_map.get(bud["staff_id"], 0) if bud["staff_id"] else actual_total
    return {"budgets": budgets, "budgeted_total": sum(b["budgeted_hours"] for b in budgets), "actual_total": actual_total}


@route("POST", r"/api/pm/engagements/(\d+)/time-budget")
def pm_create_time_budget(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    eng = db.query("SELECT client_id FROM engagement WHERE id = ?", (m.group(1),), one=True)
    if not eng:
        raise HttpError(404, "That engagement no longer exists")
    _assert_client_visible(u, eng["client_id"])
    b = _json_body(env)
    if not (b.get("task") or "").strip() or b.get("budgeted_hours") is None:
        raise HttpError(400, "A task and budgeted hours are required")
    bid = db.execute(
        "INSERT INTO pm_time_budget (engagement_id, task, staff_id, budgeted_hours, notes) VALUES (?,?,?,?,?)",
        (m.group(1), b["task"].strip(), b.get("staff_id"), float(b["budgeted_hours"]), b.get("notes")))
    return db.query("SELECT * FROM pm_time_budget WHERE id = ?", (bid,), one=True)


@route("PUT", r"/api/pm/time-budget/(\d+)")
def pm_update_time_budget(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    row = db.query("SELECT b.*, e.client_id FROM pm_time_budget b JOIN engagement e ON e.id = b.engagement_id WHERE b.id = ?",
                   (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That budget line no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["task", "staff_id", "budgeted_hours", "notes"]
    sets = [f"{f} = ?" for f in fields if f in b]
    if sets:
        db.execute(f"UPDATE pm_time_budget SET {', '.join(sets)} WHERE id = ?",
                   [b[f] for f in fields if f in b] + [m.group(1)])
    return db.query("SELECT * FROM pm_time_budget WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/pm/time-budget/(\d+)")
def pm_delete_time_budget(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    row = db.query("SELECT b.id, e.client_id FROM pm_time_budget b JOIN engagement e ON e.id = b.engagement_id WHERE b.id = ?",
                   (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That budget line no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM pm_time_budget WHERE id = ?", (m.group(1),))
    return {"ok": True}


# ---- dashboards

@route("GET", r"/api/pm/dashboard/staff-allocation")
def pm_staff_allocation_dashboard(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    staff = db.query("SELECT id, full_name FROM app_user WHERE firm_id = ? AND is_active = 1 ORDER BY full_name", (u["firm_id"],))
    rows = []
    for s in staff:
        open_assignments = db.query(
            "SELECT COUNT(*) c FROM pm_assignment WHERE assigned_to = ? AND status != 'done'", (s["id"],), one=True)["c"]
        open_engagements = db.query(
            "SELECT COUNT(*) c FROM engagement WHERE (partner_id = ? OR manager_id = ?) AND status != 'closed'",
            (s["id"], s["id"]), one=True)["c"]
        hours_30d = db.query(
            "SELECT COALESCE(SUM(hours), 0) h FROM time_entry WHERE user_id = ? AND work_date >= ?",
            (s["id"], (datetime.now(timezone.utc) - timedelta(days=30)).strftime("%Y-%m-%d")), one=True)["h"]
        rows.append({"staff_id": s["id"], "name": s["full_name"], "open_assignments": open_assignments,
                    "open_engagements": open_engagements, "hours_last_30_days": hours_30d})
    return rows


@route("GET", r"/api/pm/dashboard/outstanding-fees")
def pm_outstanding_fees_dashboard(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    rows = db.query(
        "SELECT i.id, i.invoice_no, i.client_id, c.name client_name, i.due_date, i.total, i.paid_amount,"
        " (i.total - i.paid_amount) outstanding FROM invoice i JOIN client c ON c.id = i.client_id"
        " WHERE i.firm_id = ? AND i.status IN ('sent', 'draft') AND (i.total - i.paid_amount) > 0.01"
        " ORDER BY i.due_date IS NULL, i.due_date", (u["firm_id"],))
    today = db.now_iso()[:10]
    for r in rows:
        if r["due_date"] and r["due_date"] < today:
            days = (datetime.strptime(today, "%Y-%m-%d") - datetime.strptime(r["due_date"], "%Y-%m-%d")).days
            r["days_overdue"] = days
        else:
            r["days_overdue"] = 0
    return {"invoices": rows, "total_outstanding": round(sum(r["outstanding"] for r in rows), 2)}


@route("GET", r"/api/pm/clients/(\d+)/dashboard")
def pm_client_dashboard(env, m):
    u = _user(env)
    _require_module(u, PM_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    checklist_counts = {}
    for stage in ("onboarding", "document_request", "closure"):
        rows = db.query("SELECT status FROM pm_checklist_item WHERE client_id = ? AND stage = ?", (client_id, stage))
        checklist_counts[stage] = {"total": len(rows), "done": sum(1 for r in rows if r["status"] in ("done", "received", "na"))}
    acceptance = db.query("SELECT acceptance_decision, risk_level FROM pm_acceptance WHERE client_id = ? ORDER BY id DESC LIMIT 1",
                          (client_id,), one=True)
    open_assignments = db.query("SELECT COUNT(*) c FROM pm_assignment WHERE client_id = ? AND status != 'done'",
                                (client_id,), one=True)["c"]
    outstanding = db.query(
        "SELECT COALESCE(SUM(total - paid_amount), 0) o FROM invoice WHERE client_id = ? AND status IN ('sent','draft')",
        (client_id,), one=True)["o"]
    followups_open = db.query("SELECT COUNT(*) c FROM client_followup WHERE client_id = ? AND status = 'pending'",
                              (client_id,), one=True)["c"]
    return {
        "checklist": checklist_counts, "acceptance_decision": acceptance["acceptance_decision"] if acceptance else None,
        "risk_level": acceptance["risk_level"] if acceptance else None, "open_assignments": open_assignments,
        "outstanding_fees": outstanding, "open_followups": followups_open,
    }


# --------------------------------------------------- forensic audit & investigation
# Sixteen named detectors, one normalized row shape — see the comment above
# fa_dataset in schema.sql. A dataset is uploaded as plain CSV (no header
# assumptions beyond "first row is a header, skip it") with a fixed column
# order documented in the frontend upload form: date, reference_no, party,
# description, amount, account, actor, extra1, extra2, extra3 — extra1..3
# mean pan_no/bank_account/address for payroll and vendors datasets, and are
# simply unused for the others.

FA_MODULE = "forensic_audit"
FA_DATASET_TYPES = ("journal_entries", "payments", "invoices", "payroll", "vendors", "expenses")


def _fa_parse_csv(text, dataset_type):
    reader = csv.reader(io.StringIO(text))
    rows = list(reader)
    if rows and rows[0] and rows[0][0].strip().lower() in ("date", "transaction date"):
        rows = rows[1:]  # header row
    parsed = []
    for r in rows:
        r = r + [""] * (10 - len(r))  # tolerate short rows
        date, reference_no, party, description, amount, account, actor, e1, e2, e3 = r[:10]
        try:
            amount_val = float(amount) if (amount or "").strip() else None
        except ValueError:
            amount_val = None
        extra = {}
        if dataset_type in ("payroll", "vendors"):
            extra = {"pan_no": e1.strip(), "bank_account": e2.strip(), "address": e3.strip()}
        parsed.append({"date": date.strip() or None, "reference_no": reference_no.strip() or None,
                       "party": party.strip() or None, "description": description.strip() or None,
                       "amount": amount_val, "account": account.strip() or None, "actor": actor.strip() or None,
                       "extra": extra})
    return parsed


def _fa_dataset_rows(dataset_id):
    rows = db.query("SELECT * FROM fa_transaction WHERE dataset_id = ? ORDER BY row_index", (dataset_id,))
    out = []
    for r in rows:
        d = dict(r)
        d["extra"] = json.loads(d["extra_json"]) if d["extra_json"] else {}
        out.append(d)
    return out


@route("GET", r"/api/fa/clients/(\d+)/datasets")
def fa_list_datasets(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM fa_dataset WHERE client_id = ? ORDER BY uploaded_at DESC", (client_id,))


@route("POST", r"/api/fa/clients/(\d+)/datasets")
def fa_create_dataset(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if b.get("dataset_type") not in FA_DATASET_TYPES:
        raise HttpError(400, "Unknown dataset type")
    if not (b.get("name") or "").strip():
        raise HttpError(400, "A dataset needs a name")
    csv_text = b.get("csv_text") or ""
    parsed = _fa_parse_csv(csv_text, b["dataset_type"])
    parsed = [r for r in parsed if any(v not in (None, "", {}) for v in r.values())]
    if not parsed:
        raise HttpError(400, "No usable rows found in that CSV — check the column order (date, reference_no, party, description, amount, account, actor, extra1, extra2, extra3)")
    did = db.execute(
        "INSERT INTO fa_dataset (client_id, name, dataset_type, period_label, row_count, uploaded_by)"
        " VALUES (?,?,?,?,?,?)",
        (client_id, b["name"].strip(), b["dataset_type"], b.get("period_label"), len(parsed), u["id"]))
    for i, r in enumerate(parsed):
        db.execute(
            "INSERT INTO fa_transaction (dataset_id, row_index, date, reference_no, party, description, amount, account, actor, extra_json)"
            " VALUES (?,?,?,?,?,?,?,?,?,?)",
            (did, i, r["date"], r["reference_no"], r["party"], r["description"], r["amount"], r["account"], r["actor"],
             json.dumps(r["extra"]) if r["extra"] else None))
    db.audit(u["id"], "create", "fa_dataset", did, f"{b['dataset_type']}: {len(parsed)} rows")
    return db.query("SELECT * FROM fa_dataset WHERE id = ?", (did,), one=True)


@route("GET", r"/api/fa/datasets/(\d+)/transactions")
def fa_list_transactions(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    ds = db.query("SELECT client_id FROM fa_dataset WHERE id = ?", (m.group(1),), one=True)
    if not ds:
        raise HttpError(404, "That dataset no longer exists")
    _assert_client_visible(u, ds["client_id"])
    return _fa_dataset_rows(int(m.group(1)))[:2000]


@route("DELETE", r"/api/fa/datasets/(\d+)")
def fa_delete_dataset(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    ds = db.query("SELECT client_id FROM fa_dataset WHERE id = ?", (m.group(1),), one=True)
    if not ds:
        raise HttpError(404, "That dataset no longer exists")
    _assert_client_visible(u, ds["client_id"])
    db.execute("DELETE FROM fa_dataset WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("POST", r"/api/fa/datasets/(\d+)/analyze")
def fa_analyze_dataset(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    dataset_id = int(m.group(1))
    ds = db.query("SELECT * FROM fa_dataset WHERE id = ?", (dataset_id,), one=True)
    if not ds:
        raise HttpError(404, "That dataset no longer exists")
    _assert_client_visible(u, ds["client_id"])
    b = _json_body(env)
    rows = _fa_dataset_rows(dataset_id)
    related_rows = None
    if b.get("related_dataset_id"):
        related_ds = db.query("SELECT client_id FROM fa_dataset WHERE id = ?", (b["related_dataset_id"],), one=True)
        if related_ds and related_ds["client_id"] == ds["client_id"]:
            related_rows = _fa_dataset_rows(b["related_dataset_id"])
    findings = forensic_engines.run_all_detectors(ds["dataset_type"], rows, b.get("params") or {}, related_rows)
    saved = []
    for f in findings:
        txn_id = None
        if len(f["row_indices"]) == 1 and rows:
            txn_id = rows[f["row_indices"][0]]["id"] if f["row_indices"][0] < len(rows) else None
        fid = db.execute(
            "INSERT INTO fa_finding (dataset_id, client_id, transaction_id, detector, severity, title, description, evidence_json)"
            " VALUES (?,?,?,?,?,?,?,?)",
            (dataset_id, ds["client_id"], txn_id, f["detector"], f["severity"], f["title"], f["description"],
             json.dumps(f.get("evidence") or {}, default=str)))
        row = db.query("SELECT * FROM fa_finding WHERE id = ?", (fid,), one=True)
        row["row_indices"] = f["row_indices"]
        saved.append(row)
    db.audit(u["id"], "create", "fa_finding", None, f"{len(saved)} findings from dataset {dataset_id}")
    return {"findings": saved, "count": len(saved)}


@route("POST", r"/api/fa/datasets/(\d+)/analyze/revenue")
def fa_analyze_revenue(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    dataset_id = int(m.group(1))
    ds = db.query("SELECT * FROM fa_dataset WHERE id = ?", (dataset_id,), one=True)
    if not ds:
        raise HttpError(404, "That dataset no longer exists")
    _assert_client_visible(u, ds["client_id"])
    b = _json_body(env)
    rows = _fa_dataset_rows(dataset_id)
    findings = forensic_engines.analyze_revenue(rows, b.get("period_end_date"))
    saved = []
    for f in findings:
        txn_id = rows[f["row_indices"][0]]["id"] if len(f["row_indices"]) == 1 else None
        fid = db.execute(
            "INSERT INTO fa_finding (dataset_id, client_id, transaction_id, detector, severity, title, description, evidence_json)"
            " VALUES (?,?,?,?,?,?,?,?)",
            (dataset_id, ds["client_id"], txn_id, "revenue_manipulation", "Medium", f["type"].replace("_", " ").title(),
             f["detail"], "{}"))
        saved.append(db.query("SELECT * FROM fa_finding WHERE id = ?", (fid,), one=True))
    return {"findings": saved, "count": len(saved)}


@route("POST", r"/api/fa/datasets/(\d+)/analyze/cash")
def fa_analyze_cash(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    dataset_id = int(m.group(1))
    ds = db.query("SELECT * FROM fa_dataset WHERE id = ?", (dataset_id,), one=True)
    if not ds:
        raise HttpError(404, "That dataset no longer exists")
    _assert_client_visible(u, ds["client_id"])
    rows = _fa_dataset_rows(dataset_id)
    findings = forensic_engines.analyze_cash(rows)
    saved = []
    for f in findings:
        txn_id = rows[f["row_indices"][0]]["id"] if len(f["row_indices"]) == 1 else None
        fid = db.execute(
            "INSERT INTO fa_finding (dataset_id, client_id, transaction_id, detector, severity, title, description, evidence_json)"
            " VALUES (?,?,?,?,?,?,?,?)",
            (dataset_id, ds["client_id"], txn_id, "cash_leakage", "Medium", f["type"].replace("_", " ").title(), f["detail"], "{}"))
        saved.append(db.query("SELECT * FROM fa_finding WHERE id = ?", (fid,), one=True))
    return {"findings": saved, "count": len(saved)}


@route("POST", r"/api/fa/clients/(\d+)/beneish")
def fa_beneish(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    result = forensic_engines.beneish_m_score(b.get("curr") or {}, b.get("prior") or {})
    if b.get("save") and result.get("m_score") is not None:
        fid = db.execute(
            "INSERT INTO fa_finding (dataset_id, client_id, detector, severity, title, description, evidence_json)"
            " VALUES (?,?,?,?,?,?,?)",
            (None, client_id, "beneish_m_score", "High" if result["interpretation"].startswith("Possible") else "Low",
             f"Beneish M-Score: {result['m_score']}", result["interpretation"], json.dumps(result)))
        result["finding_id"] = fid
    return result


@route("GET", r"/api/fa/clients/(\d+)/findings")
def fa_list_findings(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    sql = "SELECT * FROM fa_finding WHERE client_id = ?"
    args = [client_id]
    for param, col in (("dataset_id", "dataset_id"), ("detector", "detector"), ("severity", "severity"), ("status", "status")):
        v = (qs.get(param) or [None])[0]
        if v:
            sql += f" AND {col} = ?"; args.append(v)
    return db.query(sql + " ORDER BY severity = 'High' DESC, severity = 'Medium' DESC, created_at DESC", args)


@route("PUT", r"/api/fa/findings/(\d+)")
def fa_update_finding(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    row = db.query("SELECT client_id FROM fa_finding WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That finding no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    status = b.get("status")
    if status not in (None, "open", "reviewed", "confirmed", "false_positive"):
        raise HttpError(400, "Unknown status")
    sets, args = [], []
    if status:
        sets += ["status = ?", "reviewed_by = ?", "reviewed_at = ?"]; args += [status, u["id"], db.now_iso()]
    if "notes" in b:
        sets.append("notes = ?"); args.append(b["notes"])
    if sets:
        args.append(m.group(1))
        db.execute(f"UPDATE fa_finding SET {', '.join(sets)} WHERE id = ?", args)
    return db.query("SELECT * FROM fa_finding WHERE id = ?", (m.group(1),), one=True)


@route("GET", r"/api/fa/clients/(\d+)/working-papers")
def fa_list_working_papers(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query(
        "SELECT wp.*, pu.full_name prepared_by_name, ru.full_name reviewed_by_name FROM fa_working_paper wp"
        " LEFT JOIN app_user pu ON pu.id = wp.prepared_by LEFT JOIN app_user ru ON ru.id = wp.reviewed_by"
        " WHERE wp.client_id = ? ORDER BY wp.ref", (client_id,))


@route("POST", r"/api/fa/clients/(\d+)/working-papers")
def fa_create_working_paper(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("ref") or "").strip():
        raise HttpError(400, "A working paper needs a reference (e.g. FA-01)")
    wid = db.execute(
        "INSERT INTO fa_working_paper (client_id, dataset_id, ref, title, content, prepared_by, prepared_at)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b.get("dataset_id"), b["ref"].strip(), b.get("title"), b.get("content"), u["id"], db.now_iso()))
    return db.query("SELECT * FROM fa_working_paper WHERE id = ?", (wid,), one=True)


@route("PUT", r"/api/fa/working-papers/(\d+)")
def fa_update_working_paper(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    row = db.query("SELECT * FROM fa_working_paper WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That working paper no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    sets, args = [], []
    for f in ("title", "content"):
        if f in b:
            sets.append(f"{f} = ?"); args.append(b[f])
    if "review_status" in b:
        sets += ["review_status = ?", "review_comment = ?", "reviewed_by = ?", "reviewed_at = ?"]
        args += [b["review_status"], b.get("review_comment"), u["id"], db.now_iso()]
    if sets:
        args.append(m.group(1))
        db.execute(f"UPDATE fa_working_paper SET {', '.join(sets)} WHERE id = ?", args)
    return db.query("SELECT * FROM fa_working_paper WHERE id = ?", (m.group(1),), one=True)


@route("GET", r"/api/fa/clients/(\d+)/dashboard")
def fa_client_dashboard(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    by_severity = {r["severity"]: r["c"] for r in db.query(
        "SELECT severity, COUNT(*) c FROM fa_finding WHERE client_id = ? AND status != 'false_positive' GROUP BY severity", (client_id,))}
    by_detector = db.query(
        "SELECT detector, COUNT(*) c FROM fa_finding WHERE client_id = ? AND status != 'false_positive' GROUP BY detector ORDER BY c DESC",
        (client_id,))
    open_count = db.query("SELECT COUNT(*) c FROM fa_finding WHERE client_id = ? AND status = 'open'", (client_id,), one=True)["c"]
    dataset_count = db.query("SELECT COUNT(*) c FROM fa_dataset WHERE client_id = ?", (client_id,), one=True)["c"]
    return {"by_severity": {"Low": by_severity.get("Low", 0), "Medium": by_severity.get("Medium", 0), "High": by_severity.get("High", 0)},
            "by_detector": by_detector, "open_findings": open_count, "datasets": dataset_count}


@route("POST", r"/api/fa/clients/(\d+)/reports")
def fa_generate_report(env, m):
    u = _user(env)
    _require_module(u, FA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    report_type = b.get("report_type")
    fmt = b.get("format", "docx")
    if report_type not in ("investigation_report", "working_paper_pack"):
        raise HttpError(400, "Unknown report_type")

    client = db.query("SELECT * FROM client WHERE id = ?", (client_id,), one=True)
    findings = db.query(
        "SELECT f.*, d.name dataset_name, d.dataset_type FROM fa_finding f LEFT JOIN fa_dataset d ON d.id = f.dataset_id"
        " WHERE f.client_id = ? AND f.status != 'false_positive' ORDER BY f.severity = 'High' DESC, f.severity = 'Medium' DESC",
        (client_id,))
    working_papers = db.query(
        "SELECT wp.*, pu.full_name prepared_by_name, ru.full_name reviewed_by_name FROM fa_working_paper wp"
        " LEFT JOIN app_user pu ON pu.id = wp.prepared_by LEFT JOIN app_user ru ON ru.id = wp.reviewed_by"
        " WHERE wp.client_id = ? ORDER BY wp.ref", (client_id,))
    datasets = db.query("SELECT * FROM fa_dataset WHERE client_id = ?", (client_id,))
    result = {"client": dict(client) if client else {}, "findings": findings, "working_papers": working_papers,
              "datasets": datasets, "unverified": []}
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": client["name"] if client else "", "fy": "", "unverified": [],
            "title": reports.TITLES.get("forensic_audit", "Forensic audit")}
    try:
        path, filename = reports.build("forensic_audit", f"{fmt}_{report_type}", result, meta, REPORT_DIR)
    except ValueError as e:
        raise HttpError(400, str(e))
    rid = db.execute(
        "INSERT INTO report (module_key, format, filename, storage_path, params, status, generated_by, byte_size)"
        " VALUES (?,?,?,?,?,?,?,?)",
        ("forensic_audit", fmt, filename, path, json.dumps({"report_type": report_type, "client_id": client_id}),
         "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "report", rid, f"forensic_audit.{report_type}.{fmt}")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download", "byte_size": os.path.getsize(path)}


# ------------------------------------------------------------ corporate advisory
CA_MODULE = "corporate_advisory"

CA_CHECKLIST_TEMPLATES = {
    "Incorporation": [
        "Name reservation obtained from OCR", "MOA / AOA drafted and adopted",
        "Minimum subscribers / promoters identified", "Registration fee paid",
        "Certificate of incorporation obtained", "PAN registration completed",
        "VAT registration completed (if applicable)", "Statutory registers opened",
        "First board meeting held and minuted", "Bank account opened",
    ],
    "Statutory registers": [
        "Register of shareholders maintained", "Register of directors maintained",
        "Register of share transfers maintained", "Register of charges maintained (if any borrowing is secured)",
        "Minute books for board and general meetings maintained",
    ],
    "AGM": [
        "AGM held within the statutory period from fiscal year-end", "Notice period for AGM observed",
        "Audited financial statements presented and approved", "Auditor appointed/reappointed",
        "AGM minutes recorded and signed", "AGM resolutions filed with OCR where required",
    ],
    "Board meetings": [
        "Board meetings held at the frequency required by the AOA / statute",
        "Notice given for each board meeting", "Quorum confirmed and recorded for each meeting",
        "Minutes recorded and signed for each meeting", "Minutes filed where required",
    ],
    "Filings": [
        "Annual return filed with OCR", "Audited financial statements filed with OCR",
        "Any share capital change filed with OCR", "Any change in directors filed with OCR",
        "Any change in registered office filed with OCR",
    ],
}


def _ca_client(client_id):
    return db.query("SELECT * FROM client WHERE id = ?", (client_id,), one=True)


@route("GET", r"/api/ca/clients/(\d+)/profile")
def ca_get_profile(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ca_entity_profile WHERE client_id = ?", (client_id,), one=True) or {}


@route("PUT", r"/api/ca/clients/(\d+)/profile")
def ca_put_profile(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    fields = ["registration_no", "incorporation_date", "entity_class", "authorized_capital", "issued_capital",
              "paid_up_capital", "share_face_value", "min_directors", "max_directors",
              "board_meeting_gap_days_max", "auditor_name", "notes"]
    existing = db.query("SELECT client_id FROM ca_entity_profile WHERE client_id = ?", (client_id,), one=True)
    cols = [f for f in fields if f in b]
    if existing:
        if cols:
            db.execute(f"UPDATE ca_entity_profile SET {', '.join(c + ' = ?' for c in cols)}, updated_at = ? WHERE client_id = ?",
                       [b[c] for c in cols] + [db.now_iso(), client_id])
    else:
        all_cols = ["client_id"] + cols
        db.execute(f"INSERT INTO ca_entity_profile ({', '.join(all_cols)}) VALUES ({', '.join('?' for _ in all_cols)})",
                   [client_id] + [b[c] for c in cols])
    db.audit(u["id"], "update", "ca_entity_profile", client_id, None)
    return ca_get_profile(env, m)


# ---- shareholder register

@route("GET", r"/api/ca/clients/(\d+)/shareholders")
def ca_list_shareholders(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ca_shareholder WHERE client_id = ? ORDER BY shares_held DESC", (client_id,))


@route("POST", r"/api/ca/clients/(\d+)/shareholders")
def ca_create_shareholder(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("name") or "").strip():
        raise HttpError(400, "A shareholder needs a name")
    sid = db.execute(
        "INSERT INTO ca_shareholder (client_id, name, pan, address, share_class, shares_held, is_related_party,"
        " is_director, since_date, notes) VALUES (?,?,?,?,?,?,?,?,?,?)",
        (client_id, b["name"].strip(), b.get("pan"), b.get("address"), b.get("share_class", "equity"),
         b.get("shares_held", 0), 1 if b.get("is_related_party") else 0, 1 if b.get("is_director") else 0,
         b.get("since_date"), b.get("notes")))
    return db.query("SELECT * FROM ca_shareholder WHERE id = ?", (sid,), one=True)


@route("PUT", r"/api/ca/shareholders/(\d+)")
def ca_update_shareholder(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    row = db.query("SELECT client_id FROM ca_shareholder WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That shareholder no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["name", "pan", "address", "share_class", "shares_held", "is_related_party", "is_director",
              "since_date", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        vals = [(1 if b[c] else 0) if c in ("is_related_party", "is_director") else b[c] for c in cols]
        db.execute(f"UPDATE ca_shareholder SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   vals + [m.group(1)])
    return db.query("SELECT * FROM ca_shareholder WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/ca/shareholders/(\d+)")
def ca_delete_shareholder(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    row = db.query("SELECT client_id FROM ca_shareholder WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That shareholder no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ca_shareholder WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/ca/clients/(\d+)/shareholding-analysis")
def ca_shareholding_analysis(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    rows = db.query("SELECT * FROM ca_shareholder WHERE client_id = ?", (client_id,))
    return corporate_engines.shareholding_analysis(rows)


# ---- capital events register (rights/bonus/private placement/transfer/reduction/buyback)

@route("GET", r"/api/ca/clients/(\d+)/share-transactions")
def ca_list_share_transactions(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    rows = db.query("SELECT * FROM ca_share_transaction WHERE client_id = ? ORDER BY transaction_date DESC, id DESC", (client_id,))
    for r in rows:
        r["detail"] = json.loads(r["detail_json"]) if r.get("detail_json") else {}
    return rows


@route("POST", r"/api/ca/clients/(\d+)/share-transactions")
def ca_create_share_transaction(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("event_type"):
        raise HttpError(400, "A capital event needs an event_type")
    tid = db.execute(
        "INSERT INTO ca_share_transaction (client_id, event_type, shareholder_id, from_shareholder_id, shares,"
        " price_per_share, transaction_date, board_approval_date, agm_approval_date, ocr_filed_date, status,"
        " detail_json, notes, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (client_id, b["event_type"], b.get("shareholder_id"), b.get("from_shareholder_id"), b.get("shares", 0),
         b.get("price_per_share"), b.get("transaction_date"), b.get("board_approval_date"),
         b.get("agm_approval_date"), b.get("ocr_filed_date"), b.get("status", "proposed"),
         json.dumps(b.get("detail") or {}), b.get("notes"), u["id"]))
    row = db.query("SELECT * FROM ca_share_transaction WHERE id = ?", (tid,), one=True)
    row["detail"] = json.loads(row["detail_json"]) if row.get("detail_json") else {}
    return row


@route("PUT", r"/api/ca/share-transactions/(\d+)")
def ca_update_share_transaction(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    row = db.query("SELECT client_id FROM ca_share_transaction WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That capital event no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["event_type", "shareholder_id", "from_shareholder_id", "shares", "price_per_share", "transaction_date",
              "board_approval_date", "agm_approval_date", "ocr_filed_date", "status", "notes"]
    cols = [f for f in fields if f in b]
    vals = [b[c] for c in cols]
    if "detail" in b:
        cols.append("detail_json"); vals.append(json.dumps(b["detail"] or {}))
    if cols:
        db.execute(f"UPDATE ca_share_transaction SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   vals + [m.group(1)])
    out = db.query("SELECT * FROM ca_share_transaction WHERE id = ?", (m.group(1),), one=True)
    out["detail"] = json.loads(out["detail_json"]) if out.get("detail_json") else {}
    return out


@route("DELETE", r"/api/ca/share-transactions/(\d+)")
def ca_delete_share_transaction(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    row = db.query("SELECT client_id FROM ca_share_transaction WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That capital event no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ca_share_transaction WHERE id = ?", (m.group(1),))
    return {"ok": True}


# ---- calculators — compute-only, no persistence of their own

@route("POST", r"/api/ca/calculators/rights-issue")
def ca_calc_rights_issue(env, m):
    u = _user(env); _require_module(u, CA_MODULE)
    return corporate_engines.rights_issue(_json_body(env))


@route("POST", r"/api/ca/calculators/bonus-issue")
def ca_calc_bonus_issue(env, m):
    u = _user(env); _require_module(u, CA_MODULE)
    return corporate_engines.bonus_issue(_json_body(env))


@route("POST", r"/api/ca/calculators/capital-reduction")
def ca_calc_capital_reduction(env, m):
    u = _user(env); _require_module(u, CA_MODULE)
    return corporate_engines.capital_reduction(_json_body(env))


@route("POST", r"/api/ca/calculators/dividend-check")
def ca_calc_dividend_check(env, m):
    u = _user(env); _require_module(u, CA_MODULE)
    return corporate_engines.dividend_distribution_check(_json_body(env))


# ---- board meetings

@route("GET", r"/api/ca/clients/(\d+)/meetings")
def ca_list_meetings(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ca_board_meeting WHERE client_id = ? ORDER BY meeting_date DESC", (client_id,))


@route("POST", r"/api/ca/clients/(\d+)/meetings")
def ca_create_meeting(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("meeting_date"):
        raise HttpError(400, "A meeting needs a date")
    mid = db.execute(
        "INSERT INTO ca_board_meeting (client_id, meeting_type, meeting_date, notice_date, quorum_present, agenda,"
        " attendees, minutes_recorded, minutes_filed_date, status, notes) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
        (client_id, b.get("meeting_type", "board"), b["meeting_date"], b.get("notice_date"),
         b.get("quorum_present"), b.get("agenda"), b.get("attendees"), 1 if b.get("minutes_recorded") else 0,
         b.get("minutes_filed_date"), b.get("status", "scheduled"), b.get("notes")))
    return db.query("SELECT * FROM ca_board_meeting WHERE id = ?", (mid,), one=True)


@route("PUT", r"/api/ca/meetings/(\d+)")
def ca_update_meeting(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    row = db.query("SELECT client_id FROM ca_board_meeting WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That meeting no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["meeting_type", "meeting_date", "notice_date", "quorum_present", "agenda", "attendees",
              "minutes_recorded", "minutes_filed_date", "status", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        vals = [(1 if b[c] else 0) if c == "minutes_recorded" else b[c] for c in cols]
        db.execute(f"UPDATE ca_board_meeting SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   vals + [m.group(1)])
    return db.query("SELECT * FROM ca_board_meeting WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/ca/meetings/(\d+)")
def ca_delete_meeting(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    row = db.query("SELECT client_id FROM ca_board_meeting WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That meeting no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ca_board_meeting WHERE id = ?", (m.group(1),))
    return {"ok": True}


# ---- board / shareholder resolutions

@route("GET", r"/api/ca/clients/(\d+)/resolutions")
def ca_list_resolutions(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ca_resolution WHERE client_id = ? ORDER BY id DESC", (client_id,))


@route("POST", r"/api/ca/clients/(\d+)/resolutions")
def ca_create_resolution(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if b.get("resolution_type") not in ("board", "ordinary", "special"):
        raise HttpError(400, "Unknown resolution_type")
    rid = db.execute(
        "INSERT INTO ca_resolution (client_id, meeting_id, resolution_type, category, title, resolution_text,"
        " resolution_date, status, filed_with, reference_no, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
        (client_id, b.get("meeting_id"), b["resolution_type"], b.get("category"), b.get("title"),
         b.get("resolution_text"), b.get("resolution_date"), b.get("status", "draft"), b.get("filed_with"),
         b.get("reference_no"), u["id"]))
    return db.query("SELECT * FROM ca_resolution WHERE id = ?", (rid,), one=True)


@route("PUT", r"/api/ca/resolutions/(\d+)")
def ca_update_resolution(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    row = db.query("SELECT client_id FROM ca_resolution WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That resolution no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["meeting_id", "resolution_type", "category", "title", "resolution_text", "resolution_date",
              "status", "filed_with", "reference_no"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE ca_resolution SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    return db.query("SELECT * FROM ca_resolution WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/ca/resolutions/(\d+)")
def ca_delete_resolution(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    row = db.query("SELECT client_id FROM ca_resolution WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That resolution no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ca_resolution WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("POST", r"/api/ca/resolutions/(\d+)/document")
def ca_resolution_document(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    row = db.query("SELECT * FROM ca_resolution WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That resolution no longer exists")
    _assert_client_visible(u, row["client_id"])
    client = _ca_client(row["client_id"])
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": client["name"] if client else "", "fy": "", "unverified": [],
            "title": reports.TITLES.get("corporate_advisory", "Corporate advisory")}
    result = {"resolution": row, "client": dict(client) if client else {}}
    try:
        path, filename = reports.build("corporate_advisory", "docx_resolution", result, meta, REPORT_DIR)
    except ValueError as e:
        raise HttpError(400, str(e))
    rid = db.execute(
        "INSERT INTO report (module_key, format, filename, storage_path, params, status, generated_by, byte_size)"
        " VALUES (?,?,?,?,?,?,?,?)",
        ("corporate_advisory", "docx", filename, path, json.dumps({"resolution_id": row["id"]}),
         "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "report", rid, "corporate_advisory.resolution.docx")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download", "byte_size": os.path.getsize(path)}


# ---- board compliance derived view

@route("GET", r"/api/ca/clients/(\d+)/board-compliance")
def ca_board_compliance(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    meetings = db.query("SELECT * FROM ca_board_meeting WHERE client_id = ?", (client_id,))
    profile = db.query("SELECT board_meeting_gap_days_max FROM ca_entity_profile WHERE client_id = ?", (client_id,), one=True)
    max_gap = profile["board_meeting_gap_days_max"] if profile else None
    return corporate_engines.board_compliance(meetings, max_gap)


@route("GET", r"/api/ca/clients/(\d+)/share-transaction-compliance")
def ca_share_transaction_compliance(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    txns = db.query("SELECT * FROM ca_share_transaction WHERE client_id = ?", (client_id,))
    return corporate_engines.share_transaction_compliance(txns)


# ---- general company-law checklist (also covers Incorporation Advisory's tracked steps)

@route("GET", r"/api/ca/clients/(\d+)/checklist")
def ca_list_checklist(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    area = (qs.get("area") or [None])[0]
    sql = "SELECT * FROM ca_checklist_item WHERE client_id = ?"
    args = [client_id]
    if area:
        sql += " AND area = ?"; args.append(area)
    return db.query(sql + " ORDER BY area, id", args)


@route("POST", r"/api/ca/clients/(\d+)/checklist")
def ca_create_checklist_item(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("area") or "").strip() or not (b.get("criterion") or "").strip():
        raise HttpError(400, "A checklist item needs an area and a criterion")
    cid = db.execute(
        "INSERT INTO ca_checklist_item (client_id, area, criterion, due_date, evidence, remark, created_by)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b["area"].strip(), b["criterion"].strip(), b.get("due_date"), b.get("evidence"),
         b.get("remark"), u["id"]))
    return db.query("SELECT * FROM ca_checklist_item WHERE id = ?", (cid,), one=True)


@route("POST", r"/api/ca/clients/(\d+)/checklist/seed")
def ca_seed_checklist(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    area = b.get("area")
    if area not in CA_CHECKLIST_TEMPLATES:
        raise HttpError(400, "Unknown area")
    existing = {r["criterion"] for r in db.query(
        "SELECT criterion FROM ca_checklist_item WHERE client_id = ? AND area = ?", (client_id, area))}
    added = 0
    for criterion in CA_CHECKLIST_TEMPLATES[area]:
        if criterion in existing:
            continue
        db.execute("INSERT INTO ca_checklist_item (client_id, area, criterion, created_by) VALUES (?,?,?,?)",
                   (client_id, area, criterion, u["id"]))
        added += 1
    return {"added": added}


@route("PUT", r"/api/ca/checklist/(\d+)")
def ca_update_checklist_item(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    row = db.query("SELECT client_id FROM ca_checklist_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That checklist item no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["criterion", "status", "due_date", "evidence", "remark"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE ca_checklist_item SET {', '.join(c + ' = ?' for c in cols)}, updated_at = ? WHERE id = ?",
                   [b[c] for c in cols] + [db.now_iso(), m.group(1)])
    return db.query("SELECT * FROM ca_checklist_item WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/ca/checklist/(\d+)")
def ca_delete_checklist_item(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    row = db.query("SELECT client_id FROM ca_checklist_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That checklist item no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ca_checklist_item WHERE id = ?", (m.group(1),))
    return {"ok": True}


# ---- working papers

@route("GET", r"/api/ca/clients/(\d+)/working-papers")
def ca_list_working_papers(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ca_working_paper WHERE client_id = ? ORDER BY ref", (client_id,))


@route("POST", r"/api/ca/clients/(\d+)/working-papers")
def ca_create_working_paper(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("ref") or "").strip():
        raise HttpError(400, "A working paper needs a reference")
    wid = db.execute(
        "INSERT INTO ca_working_paper (client_id, ref, title, content, prepared_by, prepared_at)"
        " VALUES (?,?,?,?,?,?)",
        (client_id, b["ref"].strip(), b.get("title"), b.get("content"), u["id"], db.now_iso()))
    return db.query("SELECT * FROM ca_working_paper WHERE id = ?", (wid,), one=True)


@route("PUT", r"/api/ca/working-papers/(\d+)")
def ca_update_working_paper(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    row = db.query("SELECT client_id FROM ca_working_paper WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That working paper no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["ref", "title", "content", "review_status", "review_comment"]
    cols = [f for f in fields if f in b]
    vals = [b[c] for c in cols]
    if "review_status" in b and b["review_status"] in ("approved", "rejected", "sent_back"):
        cols += ["reviewed_by", "reviewed_at"]; vals += [u["id"], db.now_iso()]
    if cols:
        db.execute(f"UPDATE ca_working_paper SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   vals + [m.group(1)])
    return db.query("SELECT * FROM ca_working_paper WHERE id = ?", (m.group(1),), one=True)


# ---- dashboard & reports

@route("GET", r"/api/ca/clients/(\d+)/dashboard")
def ca_dashboard(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    profile = db.query("SELECT * FROM ca_entity_profile WHERE client_id = ?", (client_id,), one=True) or {}
    shareholders = db.query("SELECT * FROM ca_shareholder WHERE client_id = ?", (client_id,))
    meetings = db.query("SELECT * FROM ca_board_meeting WHERE client_id = ?", (client_id,))
    txns = db.query("SELECT * FROM ca_share_transaction WHERE client_id = ?", (client_id,))
    checklist = db.query("SELECT * FROM ca_checklist_item WHERE client_id = ?", (client_id,))
    checklist_done = len([c for c in checklist if c["status"] == "done"])
    return {
        "profile": profile,
        "shareholding": corporate_engines.shareholding_analysis(shareholders),
        "board_compliance": corporate_engines.board_compliance(meetings, profile.get("board_meeting_gap_days_max")),
        "share_transaction_compliance": corporate_engines.share_transaction_compliance(txns),
        "checklist_total": len(checklist), "checklist_done": checklist_done,
        "checklist_pct": round(checklist_done / len(checklist) * 100, 1) if checklist else 0.0,
    }


@route("POST", r"/api/ca/clients/(\d+)/reports")
def ca_generate_report(env, m):
    u = _user(env)
    _require_module(u, CA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    fmt = b.get("format", "docx")
    client = _ca_client(client_id)
    profile = db.query("SELECT * FROM ca_entity_profile WHERE client_id = ?", (client_id,), one=True) or {}
    shareholders = db.query("SELECT * FROM ca_shareholder WHERE client_id = ?", (client_id,))
    meetings = db.query("SELECT * FROM ca_board_meeting WHERE client_id = ? ORDER BY meeting_date", (client_id,))
    txns = db.query("SELECT * FROM ca_share_transaction WHERE client_id = ? ORDER BY transaction_date", (client_id,))
    resolutions = db.query("SELECT * FROM ca_resolution WHERE client_id = ? ORDER BY id", (client_id,))
    checklist = db.query("SELECT * FROM ca_checklist_item WHERE client_id = ? ORDER BY area, id", (client_id,))
    result = {
        "client": dict(client) if client else {}, "profile": profile,
        "shareholding": corporate_engines.shareholding_analysis(shareholders),
        "board_compliance": corporate_engines.board_compliance(meetings, profile.get("board_meeting_gap_days_max")),
        "meetings": meetings,
        "share_transaction_compliance": corporate_engines.share_transaction_compliance(txns),
        "resolutions": resolutions, "checklist": checklist, "unverified": [],
    }
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": client["name"] if client else "", "fy": "", "unverified": [],
            "title": reports.TITLES.get("corporate_advisory", "Corporate advisory")}
    try:
        path, filename = reports.build("corporate_advisory", fmt, result, meta, REPORT_DIR)
    except ValueError as e:
        raise HttpError(400, str(e))
    rid = db.execute(
        "INSERT INTO report (module_key, format, filename, storage_path, params, status, generated_by, byte_size)"
        " VALUES (?,?,?,?,?,?,?,?)",
        ("corporate_advisory", fmt, filename, path, json.dumps({"client_id": client_id}),
         "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "report", rid, f"corporate_advisory.{fmt}")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download", "byte_size": os.path.getsize(path)}


# ------------------------------------------------------------- business advisory
BA_MODULE = "business_advisory"
BA_DIMENSION_TYPES = ("product", "customer", "branch", "overall")
BA_PLAN_SECTIONS = ("executive_summary", "company_description", "market_analysis", "business_model",
                    "marketing_sales", "operations", "management_team", "financial_plan", "risk_analysis", "appendix")


def _ba_client(client_id):
    return db.query("SELECT * FROM client WHERE id = ?", (client_id,), one=True)


# ---- profitability register (product/customer/branch-wise + overall)

@route("GET", r"/api/ba/clients/(\d+)/profitability")
def ba_list_profitability(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    dim = (qs.get("dimension_type") or [None])[0]
    sql = "SELECT * FROM ba_profitability_line WHERE client_id = ?"
    args = [client_id]
    if dim:
        sql += " AND dimension_type = ?"; args.append(dim)
    return db.query(sql + " ORDER BY period_label DESC, dimension_type, dimension_name", args)


@route("POST", r"/api/ba/clients/(\d+)/profitability")
def ba_create_profitability(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if b.get("dimension_type") not in BA_DIMENSION_TYPES:
        raise HttpError(400, "Unknown dimension_type")
    if not (b.get("dimension_name") or "").strip():
        raise HttpError(400, "A profitability line needs a dimension_name (product/customer/branch name)")
    lid = db.execute(
        "INSERT INTO ba_profitability_line (client_id, period_label, dimension_type, dimension_name, revenue,"
        " direct_cost, allocated_overhead, notes, created_by) VALUES (?,?,?,?,?,?,?,?,?)",
        (client_id, b.get("period_label", ""), b["dimension_type"], b["dimension_name"].strip(),
         b.get("revenue", 0), b.get("direct_cost", 0), b.get("allocated_overhead", 0), b.get("notes"), u["id"]))
    return db.query("SELECT * FROM ba_profitability_line WHERE id = ?", (lid,), one=True)


@route("PUT", r"/api/ba/profitability/(\d+)")
def ba_update_profitability(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    row = db.query("SELECT client_id FROM ba_profitability_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That profitability line no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["period_label", "dimension_type", "dimension_name", "revenue", "direct_cost", "allocated_overhead", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE ba_profitability_line SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    return db.query("SELECT * FROM ba_profitability_line WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/ba/profitability/(\d+)")
def ba_delete_profitability(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    row = db.query("SELECT client_id FROM ba_profitability_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That profitability line no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ba_profitability_line WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/ba/clients/(\d+)/profitability-analysis")
def ba_profitability_analysis(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    dim = (qs.get("dimension_type") or [None])[0]
    sql = "SELECT * FROM ba_profitability_line WHERE client_id = ?"
    args = [client_id]
    if dim:
        sql += " AND dimension_type = ?"; args.append(dim)
    lines = db.query(sql, args)
    return business_engines.profitability_analysis(lines)


# ---- pricing scenarios

@route("GET", r"/api/ba/clients/(\d+)/pricing")
def ba_list_pricing(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    rows = db.query("SELECT * FROM ba_pricing_scenario WHERE client_id = ? ORDER BY id DESC", (client_id,))
    for r in rows:
        r["computed"] = business_engines.pricing_recommendation(r)
    return rows


@route("POST", r"/api/ba/clients/(\d+)/pricing")
def ba_create_pricing(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("product_name") or "").strip():
        raise HttpError(400, "A pricing scenario needs a product_name")
    pid = db.execute(
        "INSERT INTO ba_pricing_scenario (client_id, product_name, unit_cost, target_margin_pct, competitor_price,"
        " current_price, notes) VALUES (?,?,?,?,?,?,?)",
        (client_id, b["product_name"].strip(), b.get("unit_cost", 0), b.get("target_margin_pct", 0),
         b.get("competitor_price"), b.get("current_price"), b.get("notes")))
    row = db.query("SELECT * FROM ba_pricing_scenario WHERE id = ?", (pid,), one=True)
    row["computed"] = business_engines.pricing_recommendation(row)
    return row


@route("PUT", r"/api/ba/pricing/(\d+)")
def ba_update_pricing(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    row = db.query("SELECT client_id FROM ba_pricing_scenario WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That pricing scenario no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["product_name", "unit_cost", "target_margin_pct", "competitor_price", "current_price", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE ba_pricing_scenario SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    out = db.query("SELECT * FROM ba_pricing_scenario WHERE id = ?", (m.group(1),), one=True)
    out["computed"] = business_engines.pricing_recommendation(out)
    return out


@route("DELETE", r"/api/ba/pricing/(\d+)")
def ba_delete_pricing(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    row = db.query("SELECT client_id FROM ba_pricing_scenario WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That pricing scenario no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ba_pricing_scenario WHERE id = ?", (m.group(1),))
    return {"ok": True}


# ---- budget vs actual

@route("GET", r"/api/ba/clients/(\d+)/budget")
def ba_list_budget(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fiscal_year_label") or [None])[0]
    sql = "SELECT * FROM ba_budget_line WHERE client_id = ?"
    args = [client_id]
    if fy:
        sql += " AND fiscal_year_label = ?"; args.append(fy)
    return db.query(sql + " ORDER BY line_type, category", args)


@route("POST", r"/api/ba/clients/(\d+)/budget")
def ba_create_budget(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("category") or "").strip() or not b.get("fiscal_year_label"):
        raise HttpError(400, "A budget line needs a fiscal_year_label and a category")
    lid = db.execute(
        "INSERT INTO ba_budget_line (client_id, fiscal_year_label, period_label, line_type, category,"
        " budgeted_amount, actual_amount, notes, created_by) VALUES (?,?,?,?,?,?,?,?,?)",
        (client_id, b["fiscal_year_label"], b.get("period_label", ""), b.get("line_type", "expense"),
         b["category"].strip(), b.get("budgeted_amount", 0), b.get("actual_amount"), b.get("notes"), u["id"]))
    return db.query("SELECT * FROM ba_budget_line WHERE id = ?", (lid,), one=True)


@route("PUT", r"/api/ba/budget/(\d+)")
def ba_update_budget(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    row = db.query("SELECT client_id FROM ba_budget_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That budget line no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["fiscal_year_label", "period_label", "line_type", "category", "budgeted_amount", "actual_amount", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE ba_budget_line SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    return db.query("SELECT * FROM ba_budget_line WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/ba/budget/(\d+)")
def ba_delete_budget(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    row = db.query("SELECT client_id FROM ba_budget_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That budget line no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ba_budget_line WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/ba/clients/(\d+)/budget-variance")
def ba_budget_variance(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fiscal_year_label") or [None])[0]
    sql = "SELECT * FROM ba_budget_line WHERE client_id = ?"
    args = [client_id]
    if fy:
        sql += " AND fiscal_year_label = ?"; args.append(fy)
    lines = db.query(sql, args)
    return business_engines.budget_variance(lines)


# ---- KPIs

@route("GET", r"/api/ba/clients/(\d+)/kpis")
def ba_list_kpis(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ba_kpi WHERE client_id = ? ORDER BY category, name", (client_id,))


@route("POST", r"/api/ba/clients/(\d+)/kpis")
def ba_create_kpi(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("name") or "").strip():
        raise HttpError(400, "A KPI needs a name")
    kid = db.execute(
        "INSERT INTO ba_kpi (client_id, name, category, unit, target_value, higher_is_better) VALUES (?,?,?,?,?,?)",
        (client_id, b["name"].strip(), b.get("category"), b.get("unit"), b.get("target_value"),
         1 if b.get("higher_is_better", True) else 0))
    return db.query("SELECT * FROM ba_kpi WHERE id = ?", (kid,), one=True)


@route("PUT", r"/api/ba/kpis/(\d+)")
def ba_update_kpi(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    row = db.query("SELECT client_id FROM ba_kpi WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That KPI no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["name", "category", "unit", "target_value", "higher_is_better"]
    cols = [f for f in fields if f in b]
    if cols:
        vals = [(1 if b[c] else 0) if c == "higher_is_better" else b[c] for c in cols]
        db.execute(f"UPDATE ba_kpi SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?", vals + [m.group(1)])
    return db.query("SELECT * FROM ba_kpi WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/ba/kpis/(\d+)")
def ba_delete_kpi(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    row = db.query("SELECT client_id FROM ba_kpi WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That KPI no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ba_kpi WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("POST", r"/api/ba/kpis/(\d+)/values")
def ba_set_kpi_value(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    kpi = db.query("SELECT client_id FROM ba_kpi WHERE id = ?", (m.group(1),), one=True)
    if not kpi:
        raise HttpError(404, "That KPI no longer exists")
    _assert_client_visible(u, kpi["client_id"])
    b = _json_body(env)
    if not b.get("period_label"):
        raise HttpError(400, "A KPI value needs a period_label")
    db.execute(
        "INSERT INTO ba_kpi_value (kpi_id, period_label, actual_value) VALUES (?,?,?)"
        " ON CONFLICT (kpi_id, period_label) DO UPDATE SET actual_value = excluded.actual_value",
        (m.group(1), b["period_label"], b.get("actual_value", 0)))
    return db.query("SELECT * FROM ba_kpi_value WHERE kpi_id = ? AND period_label = ?", (m.group(1), b["period_label"]), one=True)


@route("GET", r"/api/ba/clients/(\d+)/kpi-dashboard")
def ba_kpi_dashboard(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    kpis = db.query("SELECT * FROM ba_kpi WHERE client_id = ?", (client_id,))
    values_by_kpi = {}
    for k in kpis:
        values_by_kpi[k["id"]] = db.query("SELECT period_label, actual_value FROM ba_kpi_value WHERE kpi_id = ?", (k["id"],))
    return business_engines.kpi_dashboard(kpis, values_by_kpi)


# ---- business model canvas

@route("GET", r"/api/ba/clients/(\d+)/business-model")
def ba_get_business_model(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ba_business_model WHERE client_id = ?", (client_id,), one=True) or {}


@route("PUT", r"/api/ba/clients/(\d+)/business-model")
def ba_put_business_model(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    fields = ["value_proposition", "customer_segments", "channels", "customer_relationships", "revenue_streams",
              "key_resources", "key_activities", "key_partners", "cost_structure_notes"]
    existing = db.query("SELECT client_id FROM ba_business_model WHERE client_id = ?", (client_id,), one=True)
    cols = [f for f in fields if f in b]
    if existing:
        if cols:
            db.execute(f"UPDATE ba_business_model SET {', '.join(c + ' = ?' for c in cols)}, updated_at = ? WHERE client_id = ?",
                       [b[c] for c in cols] + [db.now_iso(), client_id])
    else:
        all_cols = ["client_id"] + cols
        db.execute(f"INSERT INTO ba_business_model ({', '.join(all_cols)}) VALUES ({', '.join('?' for _ in all_cols)})",
                   [client_id] + [b[c] for c in cols])
    return ba_get_business_model(env, m)


# ---- business plan sections

@route("GET", r"/api/ba/clients/(\d+)/plan-sections")
def ba_list_plan_sections(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ba_business_plan_section WHERE client_id = ?", (client_id,))


@route("PUT", r"/api/ba/clients/(\d+)/plan-sections/(\w+)")
def ba_put_plan_section(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    section_key = m.group(2)
    if section_key not in BA_PLAN_SECTIONS:
        raise HttpError(400, "Unknown section_key")
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    existing = db.query("SELECT id FROM ba_business_plan_section WHERE client_id = ? AND section_key = ?",
                        (client_id, section_key), one=True)
    if existing:
        db.execute("UPDATE ba_business_plan_section SET content = ?, updated_at = ? WHERE id = ?",
                   (b.get("content", ""), db.now_iso(), existing["id"]))
    else:
        db.execute("INSERT INTO ba_business_plan_section (client_id, section_key, content) VALUES (?,?,?)",
                   (client_id, section_key, b.get("content", "")))
    return db.query("SELECT * FROM ba_business_plan_section WHERE client_id = ? AND section_key = ?",
                    (client_id, section_key), one=True)


# ---- working papers

@route("GET", r"/api/ba/clients/(\d+)/working-papers")
def ba_list_working_papers(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ba_working_paper WHERE client_id = ? ORDER BY ref", (client_id,))


@route("POST", r"/api/ba/clients/(\d+)/working-papers")
def ba_create_working_paper(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("ref") or "").strip():
        raise HttpError(400, "A working paper needs a reference")
    wid = db.execute(
        "INSERT INTO ba_working_paper (client_id, ref, title, content, prepared_by, prepared_at) VALUES (?,?,?,?,?,?)",
        (client_id, b["ref"].strip(), b.get("title"), b.get("content"), u["id"], db.now_iso()))
    return db.query("SELECT * FROM ba_working_paper WHERE id = ?", (wid,), one=True)


@route("PUT", r"/api/ba/working-papers/(\d+)")
def ba_update_working_paper(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    row = db.query("SELECT client_id FROM ba_working_paper WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That working paper no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["ref", "title", "content", "review_status", "review_comment"]
    cols = [f for f in fields if f in b]
    vals = [b[c] for c in cols]
    if "review_status" in b and b["review_status"] in ("approved", "rejected", "sent_back"):
        cols += ["reviewed_by", "reviewed_at"]; vals += [u["id"], db.now_iso()]
    if cols:
        db.execute(f"UPDATE ba_working_paper SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   vals + [m.group(1)])
    return db.query("SELECT * FROM ba_working_paper WHERE id = ?", (m.group(1),), one=True)


# ---- dashboard & reports

@route("GET", r"/api/ba/clients/(\d+)/dashboard")
def ba_dashboard(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    lines = db.query("SELECT * FROM ba_profitability_line WHERE client_id = ?", (client_id,))
    budget_lines = db.query("SELECT * FROM ba_budget_line WHERE client_id = ?", (client_id,))
    kpis = db.query("SELECT * FROM ba_kpi WHERE client_id = ?", (client_id,))
    values_by_kpi = {}
    for k in kpis:
        values_by_kpi[k["id"]] = db.query("SELECT period_label, actual_value FROM ba_kpi_value WHERE kpi_id = ?", (k["id"],))
    return {
        "profitability": business_engines.profitability_analysis(lines),
        "budget_variance": business_engines.budget_variance(budget_lines),
        "kpi_dashboard": business_engines.kpi_dashboard(kpis, values_by_kpi),
    }


@route("POST", r"/api/ba/clients/(\d+)/reports")
def ba_generate_report(env, m):
    u = _user(env)
    _require_module(u, BA_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    report_type = b.get("report_type", "management_dashboard")
    fmt = b.get("format", "docx")
    client = _ba_client(client_id)
    lines = db.query("SELECT * FROM ba_profitability_line WHERE client_id = ?", (client_id,))
    budget_lines = db.query("SELECT * FROM ba_budget_line WHERE client_id = ?", (client_id,))
    kpis = db.query("SELECT * FROM ba_kpi WHERE client_id = ?", (client_id,))
    values_by_kpi = {}
    for k in kpis:
        values_by_kpi[k["id"]] = db.query("SELECT period_label, actual_value FROM ba_kpi_value WHERE kpi_id = ?", (k["id"],))
    result = {
        "client": dict(client) if client else {},
        "profitability": business_engines.profitability_analysis(lines),
        "budget_variance": business_engines.budget_variance(budget_lines),
        "kpi_dashboard": business_engines.kpi_dashboard(kpis, values_by_kpi),
        "unverified": [],
    }
    if report_type == "business_plan":
        result["business_model"] = db.query("SELECT * FROM ba_business_model WHERE client_id = ?", (client_id,), one=True) or {}
        result["plan_sections"] = db.query("SELECT * FROM ba_business_plan_section WHERE client_id = ?", (client_id,))
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": client["name"] if client else "", "fy": "", "unverified": [],
            "title": reports.TITLES.get("business_advisory", "Business advisory")}
    try:
        path, filename = reports.build("business_advisory", f"{fmt}_{report_type}", result, meta, REPORT_DIR)
    except ValueError as e:
        raise HttpError(400, str(e))
    rid = db.execute(
        "INSERT INTO report (module_key, format, filename, storage_path, params, status, generated_by, byte_size)"
        " VALUES (?,?,?,?,?,?,?,?)",
        ("business_advisory", fmt, filename, path, json.dumps({"client_id": client_id, "report_type": report_type}),
         "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "report", rid, f"business_advisory.{report_type}.{fmt}")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download", "byte_size": os.path.getsize(path)}


# --------------------------------------------------------- ipo / capital market
IPO_MODULE = "ipo_advisory"
IPO_CHECKLIST_AREAS = ("Readiness", "Eligibility", "SEBON Compliance")

IPO_CHECKLIST_TEMPLATES = {
    "Readiness": [
        "Board composition meets independence expectations", "Audit committee constituted",
        "Internal control / SOP documentation in place", "Statutory registers up to date",
        "Related-party transactions identified and disclosed", "Corporate governance policy adopted",
        "Company secretary / compliance officer appointed", "Three years of audited financial statements available",
        "Litigation and contingent liability position documented", "Promoter shareholding lock-in arrangement understood",
    ],
    "Eligibility": [
        "Minimum paid-up capital", "Net worth", "Track record of profitable operations (years)",
        "No default in loan/debenture repayment", "Promoter minimum holding after issue",
        "Company incorporated and operational for the minimum required period",
    ],
    "SEBON Compliance": [
        "Draft prospectus prepared", "Issue manager / underwriter appointed", "Credit rating obtained (if applicable)",
        "SEBON registration application filed", "CDS & Clearing registration completed",
        "Financial statements comply with NFRS", "Auditor's consent for use of report obtained",
        "Application fee and required deposits paid",
    ],
}


def _ipo_client(client_id):
    return db.query("SELECT * FROM client WHERE id = ?", (client_id,), one=True)


@route("GET", r"/api/ipo/clients/(\d+)/profile")
def ipo_get_profile(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ipo_entity_profile WHERE client_id = ?", (client_id,), one=True) or {}


@route("PUT", r"/api/ipo/clients/(\d+)/profile")
def ipo_put_profile(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    fields = ["target_listing_date", "face_value", "proposed_issue_size", "proposed_price_per_share",
              "pricing_method", "lead_manager", "notes"]
    existing = db.query("SELECT client_id FROM ipo_entity_profile WHERE client_id = ?", (client_id,), one=True)
    cols = [f for f in fields if f in b]
    if existing:
        if cols:
            db.execute(f"UPDATE ipo_entity_profile SET {', '.join(c + ' = ?' for c in cols)}, updated_at = ? WHERE client_id = ?",
                       [b[c] for c in cols] + [db.now_iso(), client_id])
    else:
        all_cols = ["client_id"] + cols
        db.execute(f"INSERT INTO ipo_entity_profile ({', '.join(all_cols)}) VALUES ({', '.join('?' for _ in all_cols)})",
                   [client_id] + [b[c] for c in cols])
    return ipo_get_profile(env, m)


# ---- checklist (Readiness / Eligibility / SEBON Compliance)

@route("GET", r"/api/ipo/clients/(\d+)/checklist")
def ipo_list_checklist(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    area = (qs.get("area") or [None])[0]
    sql = "SELECT * FROM ipo_checklist_item WHERE client_id = ?"
    args = [client_id]
    if area:
        sql += " AND area = ?"; args.append(area)
    return db.query(sql + " ORDER BY area, id", args)


@route("POST", r"/api/ipo/clients/(\d+)/checklist")
def ipo_create_checklist_item(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if b.get("area") not in IPO_CHECKLIST_AREAS:
        raise HttpError(400, "Unknown area")
    if not (b.get("criterion") or "").strip():
        raise HttpError(400, "A checklist item needs a criterion")
    cid = db.execute(
        "INSERT INTO ipo_checklist_item (client_id, area, criterion, threshold_value, actual_value, evidence, remark, created_by)"
        " VALUES (?,?,?,?,?,?,?,?)",
        (client_id, b["area"], b["criterion"].strip(), b.get("threshold_value"), b.get("actual_value"),
         b.get("evidence"), b.get("remark"), u["id"]))
    return db.query("SELECT * FROM ipo_checklist_item WHERE id = ?", (cid,), one=True)


@route("POST", r"/api/ipo/clients/(\d+)/checklist/seed")
def ipo_seed_checklist(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    area = b.get("area")
    if area not in IPO_CHECKLIST_TEMPLATES:
        raise HttpError(400, "Unknown area")
    existing = {r["criterion"] for r in db.query(
        "SELECT criterion FROM ipo_checklist_item WHERE client_id = ? AND area = ?", (client_id, area))}
    added = 0
    for criterion in IPO_CHECKLIST_TEMPLATES[area]:
        if criterion in existing:
            continue
        db.execute("INSERT INTO ipo_checklist_item (client_id, area, criterion, created_by) VALUES (?,?,?,?)",
                   (client_id, area, criterion, u["id"]))
        added += 1
    return {"added": added}


@route("PUT", r"/api/ipo/checklist/(\d+)")
def ipo_update_checklist_item(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_checklist_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That checklist item no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["criterion", "status", "threshold_value", "actual_value", "evidence", "remark"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE ipo_checklist_item SET {', '.join(c + ' = ?' for c in cols)}, updated_at = ? WHERE id = ?",
                   [b[c] for c in cols] + [db.now_iso(), m.group(1)])
    return db.query("SELECT * FROM ipo_checklist_item WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/ipo/checklist/(\d+)")
def ipo_delete_checklist_item(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_checklist_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That checklist item no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ipo_checklist_item WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/ipo/clients/(\d+)/readiness")
def ipo_readiness(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    items = db.query("SELECT * FROM ipo_checklist_item WHERE client_id = ?", (client_id,))
    return ipo_engines.readiness_summary(items)


# ---- restatement register

@route("GET", r"/api/ipo/clients/(\d+)/restatements")
def ipo_list_restatements(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ipo_restatement_line WHERE client_id = ? ORDER BY fy_label DESC, id", (client_id,))


@route("POST", r"/api/ipo/clients/(\d+)/restatements")
def ipo_create_restatement(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("account") or "").strip():
        raise HttpError(400, "A restatement line needs a fy_label and an account")
    lid = db.execute(
        "INSERT INTO ipo_restatement_line (client_id, fy_label, account, previously_reported, restated, reason)"
        " VALUES (?,?,?,?,?,?)",
        (client_id, b["fy_label"], b["account"].strip(), b.get("previously_reported", 0), b.get("restated", 0), b.get("reason")))
    return db.query("SELECT * FROM ipo_restatement_line WHERE id = ?", (lid,), one=True)


@route("PUT", r"/api/ipo/restatements/(\d+)")
def ipo_update_restatement(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_restatement_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That restatement line no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["fy_label", "account", "previously_reported", "restated", "reason"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE ipo_restatement_line SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    return db.query("SELECT * FROM ipo_restatement_line WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/ipo/restatements/(\d+)")
def ipo_delete_restatement(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_restatement_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That restatement line no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ipo_restatement_line WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/ipo/clients/(\d+)/restatement-analysis")
def ipo_restatement_analysis(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    lines = db.query("SELECT * FROM ipo_restatement_line WHERE client_id = ?", (client_id,))
    return ipo_engines.restatement_analysis(lines)


# ---- working capital adequacy

@route("GET", r"/api/ipo/clients/(\d+)/working-capital")
def ipo_list_working_capital(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ipo_working_capital_projection WHERE client_id = ? ORDER BY period_label", (client_id,))


@route("POST", r"/api/ipo/clients/(\d+)/working-capital")
def ipo_create_working_capital(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("period_label") or "").strip():
        raise HttpError(400, "A working capital projection line needs a period_label")
    lid = db.execute(
        "INSERT INTO ipo_working_capital_projection (client_id, period_label, projected_requirement,"
        " available_from_operations, ipo_proceeds_allocated, notes) VALUES (?,?,?,?,?,?)",
        (client_id, b["period_label"].strip(), b.get("projected_requirement", 0),
         b.get("available_from_operations", 0), b.get("ipo_proceeds_allocated", 0), b.get("notes")))
    return db.query("SELECT * FROM ipo_working_capital_projection WHERE id = ?", (lid,), one=True)


@route("PUT", r"/api/ipo/working-capital/(\d+)")
def ipo_update_working_capital(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_working_capital_projection WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That working capital projection line no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["period_label", "projected_requirement", "available_from_operations", "ipo_proceeds_allocated", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE ipo_working_capital_projection SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    return db.query("SELECT * FROM ipo_working_capital_projection WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/ipo/working-capital/(\d+)")
def ipo_delete_working_capital(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_working_capital_projection WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That working capital projection line no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ipo_working_capital_projection WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/ipo/clients/(\d+)/working-capital-adequacy")
def ipo_working_capital_adequacy(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    rows = db.query("SELECT * FROM ipo_working_capital_projection WHERE client_id = ?", (client_id,))
    return ipo_engines.working_capital_adequacy(rows)


# ---- peer comparison

@route("GET", r"/api/ipo/clients/(\d+)/peers")
def ipo_list_peers(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ipo_peer WHERE client_id = ? ORDER BY id", (client_id,))


@route("POST", r"/api/ipo/clients/(\d+)/peers")
def ipo_create_peer(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("peer_name") or "").strip():
        raise HttpError(400, "A peer needs a peer_name")
    pid = db.execute(
        "INSERT INTO ipo_peer (client_id, peer_name, listed, market_price, eps, book_value_per_share, roe_pct, notes)"
        " VALUES (?,?,?,?,?,?,?,?)",
        (client_id, b["peer_name"].strip(), 1 if b.get("listed", True) else 0, b.get("market_price"),
         b.get("eps"), b.get("book_value_per_share"), b.get("roe_pct"), b.get("notes")))
    return db.query("SELECT * FROM ipo_peer WHERE id = ?", (pid,), one=True)


@route("PUT", r"/api/ipo/peers/(\d+)")
def ipo_update_peer(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_peer WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That peer no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["peer_name", "listed", "market_price", "eps", "book_value_per_share", "roe_pct", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        vals = [(1 if b[c] else 0) if c == "listed" else b[c] for c in cols]
        db.execute(f"UPDATE ipo_peer SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?", vals + [m.group(1)])
    return db.query("SELECT * FROM ipo_peer WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/ipo/peers/(\d+)")
def ipo_delete_peer(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_peer WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That peer no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ipo_peer WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/ipo/clients/(\d+)/peer-comparison")
def ipo_peer_comparison(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    peers = db.query("SELECT * FROM ipo_peer WHERE client_id = ?", (client_id,))
    subject = {"eps": (qs.get("eps") or [None])[0], "book_value_per_share": (qs.get("bvps") or [None])[0],
              "proposed_price": (qs.get("proposed_price") or [None])[0]}
    return ipo_engines.peer_comparison(peers, subject)


# ---- risk factors

@route("GET", r"/api/ipo/clients/(\d+)/risks")
def ipo_list_risks(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ipo_risk_factor WHERE client_id = ? ORDER BY severity = 'High' DESC, severity = 'Medium' DESC, id", (client_id,))


@route("POST", r"/api/ipo/clients/(\d+)/risks")
def ipo_create_risk(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("description") or "").strip():
        raise HttpError(400, "A risk factor needs a description")
    rid = db.execute(
        "INSERT INTO ipo_risk_factor (client_id, category, description, mitigation, severity) VALUES (?,?,?,?,?)",
        (client_id, b.get("category"), b["description"].strip(), b.get("mitigation"), b.get("severity", "Medium")))
    return db.query("SELECT * FROM ipo_risk_factor WHERE id = ?", (rid,), one=True)


@route("PUT", r"/api/ipo/risks/(\d+)")
def ipo_update_risk(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_risk_factor WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That risk factor no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["category", "description", "mitigation", "severity"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE ipo_risk_factor SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    return db.query("SELECT * FROM ipo_risk_factor WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/ipo/risks/(\d+)")
def ipo_delete_risk(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_risk_factor WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That risk factor no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ipo_risk_factor WHERE id = ?", (m.group(1),))
    return {"ok": True}


# ---- pricing scenarios

@route("GET", r"/api/ipo/clients/(\d+)/pricing")
def ipo_list_pricing(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    rows = db.query("SELECT * FROM ipo_pricing_scenario WHERE client_id = ? ORDER BY id DESC", (client_id,))
    for r in rows:
        r["computed"] = ipo_engines.pricing_analysis(r)
    return rows


@route("POST", r"/api/ipo/clients/(\d+)/pricing")
def ipo_create_pricing(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("label") or "").strip():
        raise HttpError(400, "A pricing scenario needs a label")
    pid = db.execute(
        "INSERT INTO ipo_pricing_scenario (client_id, label, face_value, proposed_price, valuation_per_share,"
        " subject_eps, notes) VALUES (?,?,?,?,?,?,?)",
        (client_id, b["label"].strip(), b.get("face_value", 100), b.get("proposed_price", 0),
         b.get("valuation_per_share"), b.get("subject_eps"), b.get("notes")))
    row = db.query("SELECT * FROM ipo_pricing_scenario WHERE id = ?", (pid,), one=True)
    row["computed"] = ipo_engines.pricing_analysis(row)
    return row


@route("PUT", r"/api/ipo/pricing/(\d+)")
def ipo_update_pricing(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_pricing_scenario WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That pricing scenario no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["label", "face_value", "proposed_price", "valuation_per_share", "subject_eps", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE ipo_pricing_scenario SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    out = db.query("SELECT * FROM ipo_pricing_scenario WHERE id = ?", (m.group(1),), one=True)
    out["computed"] = ipo_engines.pricing_analysis(out)
    return out


@route("DELETE", r"/api/ipo/pricing/(\d+)")
def ipo_delete_pricing(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_pricing_scenario WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That pricing scenario no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM ipo_pricing_scenario WHERE id = ?", (m.group(1),))
    return {"ok": True}


# ---- working papers

@route("GET", r"/api/ipo/clients/(\d+)/working-papers")
def ipo_list_working_papers(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM ipo_working_paper WHERE client_id = ? ORDER BY ref", (client_id,))


@route("POST", r"/api/ipo/clients/(\d+)/working-papers")
def ipo_create_working_paper(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("ref") or "").strip():
        raise HttpError(400, "A working paper needs a reference")
    wid = db.execute(
        "INSERT INTO ipo_working_paper (client_id, ref, title, content, prepared_by, prepared_at) VALUES (?,?,?,?,?,?)",
        (client_id, b["ref"].strip(), b.get("title"), b.get("content"), u["id"], db.now_iso()))
    return db.query("SELECT * FROM ipo_working_paper WHERE id = ?", (wid,), one=True)


@route("PUT", r"/api/ipo/working-papers/(\d+)")
def ipo_update_working_paper(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    row = db.query("SELECT client_id FROM ipo_working_paper WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That working paper no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["ref", "title", "content", "review_status", "review_comment"]
    cols = [f for f in fields if f in b]
    vals = [b[c] for c in cols]
    if "review_status" in b and b["review_status"] in ("approved", "rejected", "sent_back"):
        cols += ["reviewed_by", "reviewed_at"]; vals += [u["id"], db.now_iso()]
    if cols:
        db.execute(f"UPDATE ipo_working_paper SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   vals + [m.group(1)])
    return db.query("SELECT * FROM ipo_working_paper WHERE id = ?", (m.group(1),), one=True)


# ---- dashboard & reports

@route("GET", r"/api/ipo/clients/(\d+)/dashboard")
def ipo_dashboard(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    profile = db.query("SELECT * FROM ipo_entity_profile WHERE client_id = ?", (client_id,), one=True) or {}
    checklist = db.query("SELECT * FROM ipo_checklist_item WHERE client_id = ?", (client_id,))
    restatements = db.query("SELECT * FROM ipo_restatement_line WHERE client_id = ?", (client_id,))
    wc_rows = db.query("SELECT * FROM ipo_working_capital_projection WHERE client_id = ?", (client_id,))
    risks = db.query("SELECT * FROM ipo_risk_factor WHERE client_id = ?", (client_id,))
    return {
        "profile": profile,
        "readiness": ipo_engines.readiness_summary(checklist),
        "restatement_analysis": ipo_engines.restatement_analysis(restatements),
        "working_capital_adequacy": ipo_engines.working_capital_adequacy(wc_rows),
        "risk_count": len(risks), "high_risk_count": len([r for r in risks if r["severity"] == "High"]),
    }


@route("POST", r"/api/ipo/clients/(\d+)/reports")
def ipo_generate_report(env, m):
    u = _user(env)
    _require_module(u, IPO_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    fmt = b.get("format", "docx")
    client = _ipo_client(client_id)
    profile = db.query("SELECT * FROM ipo_entity_profile WHERE client_id = ?", (client_id,), one=True) or {}
    checklist = db.query("SELECT * FROM ipo_checklist_item WHERE client_id = ? ORDER BY area, id", (client_id,))
    restatements = db.query("SELECT * FROM ipo_restatement_line WHERE client_id = ?", (client_id,))
    wc_rows = db.query("SELECT * FROM ipo_working_capital_projection WHERE client_id = ?", (client_id,))
    peers = db.query("SELECT * FROM ipo_peer WHERE client_id = ?", (client_id,))
    risks = db.query("SELECT * FROM ipo_risk_factor WHERE client_id = ? ORDER BY severity = 'High' DESC, id", (client_id,))
    pricing = db.query("SELECT * FROM ipo_pricing_scenario WHERE client_id = ?", (client_id,))
    result = {
        "client": dict(client) if client else {}, "profile": profile,
        "readiness": ipo_engines.readiness_summary(checklist),
        "restatement_analysis": ipo_engines.restatement_analysis(restatements),
        "working_capital_adequacy": ipo_engines.working_capital_adequacy(wc_rows),
        # Subject EPS/book value aren't on the profile — they live in the DD or
        # fin_statements workfile, and this report never silently reaches into
        # another module's data. Only the proposed price (which IS a profile
        # field) is passed through, so the comparison shows peer multiples and
        # the premium/discount against that price, without inventing a subject
        # EPS this route was never given.
        "peer_comparison": ipo_engines.peer_comparison(peers, {"proposed_price": profile.get("proposed_price_per_share")}),
        "risks": risks,
        "pricing_scenarios": [dict(p, computed=ipo_engines.pricing_analysis(p)) for p in pricing],
        "unverified": [],
    }
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": client["name"] if client else "", "fy": "", "unverified": [],
            "title": reports.TITLES.get("ipo_advisory", "IPO advisory")}
    try:
        path, filename = reports.build("ipo_advisory", fmt, result, meta, REPORT_DIR)
    except ValueError as e:
        raise HttpError(400, str(e))
    rid = db.execute(
        "INSERT INTO report (module_key, format, filename, storage_path, params, status, generated_by, byte_size)"
        " VALUES (?,?,?,?,?,?,?,?)",
        ("ipo_advisory", fmt, filename, path, json.dumps({"client_id": client_id}),
         "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "report", rid, f"ipo_advisory.{fmt}")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download", "byte_size": os.path.getsize(path)}


# ------------------------------------------------------------------ tax advisory
TAX_MODULE = "tax_advisory"
TAX_DEDUCTIBILITY_CATEGORIES = (
    "Domestic/personal", "Not wholly for business", "Fine or penalty", "General provision",
    "Capital nature", "Entertainment/gift", "Related-party not arm's length", "Other")
TAX_PE_FACTOR_TEMPLATE = [
    "Fixed place of business through which the business is wholly or partly carried on",
    "Dependent agent habitually concluding contracts on the enterprise's behalf",
    "Building site / construction or installation project exceeding the duration test",
    "Furnishing of services (including consultancy) through employees present beyond the duration test",
    "Warehouse used for delivery of goods belonging to the enterprise",
    "Server / equipment located in the jurisdiction constituting a fixed place of business",
]


def _tax_client(client_id):
    return db.query("SELECT * FROM client WHERE id = ?", (client_id,), one=True)


# ---- VAT: sales register / eligibility & matching / reconciliation

@route("GET", r"/api/tax/clients/(\d+)/vat-sales")
def tax_list_vat_sales(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    period = (qs.get("period_label") or [None])[0]
    sql = "SELECT * FROM tax_vat_sales_line WHERE client_id = ?"
    args = [client_id]
    if period:
        sql += " AND period_label = ?"; args.append(period)
    return db.query(sql + " ORDER BY period_label DESC, id", args)


@route("POST", r"/api/tax/clients/(\d+)/vat-sales")
def tax_create_vat_sales(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("period_label") or "").strip():
        raise HttpError(400, "A sales line needs a period_label")
    lid = db.execute(
        "INSERT INTO tax_vat_sales_line (client_id, period_label, invoice_no, party, taxable_sales, output_vat,"
        " declared_in_return, notes) VALUES (?,?,?,?,?,?,?,?)",
        (client_id, b["period_label"].strip(), b.get("invoice_no"), b.get("party"), b.get("taxable_sales", 0),
         b.get("output_vat", 0), 1 if b.get("declared_in_return") else 0, b.get("notes")))
    return db.query("SELECT * FROM tax_vat_sales_line WHERE id = ?", (lid,), one=True)


@route("PUT", r"/api/tax/vat-sales/(\d+)")
def tax_update_vat_sales(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_vat_sales_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That sales line no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["period_label", "invoice_no", "party", "taxable_sales", "output_vat", "declared_in_return", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        vals = [(1 if b[c] else 0) if c == "declared_in_return" else b[c] for c in cols]
        db.execute(f"UPDATE tax_vat_sales_line SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?", vals + [m.group(1)])
    return db.query("SELECT * FROM tax_vat_sales_line WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tax/vat-sales/(\d+)")
def tax_delete_vat_sales(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_vat_sales_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That sales line no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM tax_vat_sales_line WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/tax/clients/(\d+)/vat-sales-reconciliation")
def tax_vat_sales_reconciliation(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    lines = db.query("SELECT * FROM tax_vat_sales_line WHERE client_id = ?", (client_id,))
    return tax_advisory_engines.sales_vat_reconciliation(lines, (qs.get("return_output_vat") or [None])[0])


@route("GET", r"/api/tax/clients/(\d+)/vat-purchases")
def tax_list_vat_purchases(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    period = (qs.get("period_label") or [None])[0]
    sql = "SELECT * FROM tax_vat_purchase_line WHERE client_id = ?"
    args = [client_id]
    if period:
        sql += " AND period_label = ?"; args.append(period)
    return db.query(sql + " ORDER BY period_label DESC, id", args)


@route("POST", r"/api/tax/clients/(\d+)/vat-purchases")
def tax_create_vat_purchase(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("period_label") or "").strip():
        raise HttpError(400, "A purchase line needs a period_label")
    lid = db.execute(
        "INSERT INTO tax_vat_purchase_line (client_id, period_label, invoice_no, party, taxable_purchase, input_vat,"
        " eligible, ineligibility_reason, matched_with_supplier, notes) VALUES (?,?,?,?,?,?,?,?,?,?)",
        (client_id, b["period_label"].strip(), b.get("invoice_no"), b.get("party"), b.get("taxable_purchase", 0),
         b.get("input_vat", 0), 1 if b.get("eligible", True) else 0, b.get("ineligibility_reason"),
         1 if b.get("matched_with_supplier") else 0, b.get("notes")))
    return db.query("SELECT * FROM tax_vat_purchase_line WHERE id = ?", (lid,), one=True)


@route("PUT", r"/api/tax/vat-purchases/(\d+)")
def tax_update_vat_purchase(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_vat_purchase_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That purchase line no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["period_label", "invoice_no", "party", "taxable_purchase", "input_vat", "eligible",
              "ineligibility_reason", "matched_with_supplier", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        vals = [(1 if b[c] else 0) if c in ("eligible", "matched_with_supplier") else b[c] for c in cols]
        db.execute(f"UPDATE tax_vat_purchase_line SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?", vals + [m.group(1)])
    return db.query("SELECT * FROM tax_vat_purchase_line WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tax/vat-purchases/(\d+)")
def tax_delete_vat_purchase(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_vat_purchase_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That purchase line no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM tax_vat_purchase_line WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/tax/clients/(\d+)/vat-purchases-reconciliation")
def tax_vat_purchases_reconciliation(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    lines = db.query("SELECT * FROM tax_vat_purchase_line WHERE client_id = ?", (client_id,))
    return tax_advisory_engines.purchase_vat_reconciliation(lines, (qs.get("return_input_vat") or [None])[0])


# ---- TDS reconciliation

@route("GET", r"/api/tax/clients/(\d+)/tds-reconciliation")
def tax_list_tds_reconciliation(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM tax_tds_reconciliation_line WHERE client_id = ? ORDER BY fy_label DESC, id", (client_id,))


@route("POST", r"/api/tax/clients/(\d+)/tds-reconciliation")
def tax_create_tds_reconciliation(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label"):
        raise HttpError(400, "A TDS reconciliation line needs a fy_label")
    lid = db.execute(
        "INSERT INTO tax_tds_reconciliation_line (client_id, fy_label, section, payee, base_amount, tds_deducted,"
        " tds_deposited, return_filed, notes) VALUES (?,?,?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b.get("section"), b.get("payee"), b.get("base_amount", 0),
         b.get("tds_deducted", 0), b.get("tds_deposited", 0), 1 if b.get("return_filed") else 0, b.get("notes")))
    return db.query("SELECT * FROM tax_tds_reconciliation_line WHERE id = ?", (lid,), one=True)


@route("PUT", r"/api/tax/tds-reconciliation/(\d+)")
def tax_update_tds_reconciliation(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_tds_reconciliation_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That TDS reconciliation line no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["fy_label", "section", "payee", "base_amount", "tds_deducted", "tds_deposited", "return_filed", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        vals = [(1 if b[c] else 0) if c == "return_filed" else b[c] for c in cols]
        db.execute(f"UPDATE tax_tds_reconciliation_line SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?", vals + [m.group(1)])
    return db.query("SELECT * FROM tax_tds_reconciliation_line WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tax/tds-reconciliation/(\d+)")
def tax_delete_tds_reconciliation(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_tds_reconciliation_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That TDS reconciliation line no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM tax_tds_reconciliation_line WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/tax/clients/(\d+)/tds-reconciliation-summary")
def tax_tds_reconciliation_summary(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    lines = db.query("SELECT * FROM tax_tds_reconciliation_line WHERE client_id = ?", (client_id,))
    return tax_advisory_engines.tds_reconciliation(lines)


# ---- deferred tax provision

@route("GET", r"/api/tax/clients/(\d+)/provision")
def tax_list_provision(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM tax_provision_line WHERE client_id = ? ORDER BY fy_label DESC, id", (client_id,))


@route("POST", r"/api/tax/clients/(\d+)/provision")
def tax_create_provision(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("particular") or "").strip():
        raise HttpError(400, "A provision line needs a fy_label and a particular")
    lid = db.execute(
        "INSERT INTO tax_provision_line (client_id, fy_label, particular, book_value, tax_value, notes) VALUES (?,?,?,?,?,?)",
        (client_id, b["fy_label"], b["particular"].strip(), b.get("book_value", 0), b.get("tax_value", 0), b.get("notes")))
    return db.query("SELECT * FROM tax_provision_line WHERE id = ?", (lid,), one=True)


@route("PUT", r"/api/tax/provision/(\d+)")
def tax_update_provision(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_provision_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That provision line no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["fy_label", "particular", "book_value", "tax_value", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE tax_provision_line SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    return db.query("SELECT * FROM tax_provision_line WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tax/provision/(\d+)")
def tax_delete_provision(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_provision_line WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That provision line no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM tax_provision_line WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/tax/clients/(\d+)/deferred-tax")
def tax_deferred_tax(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    lines = db.query("SELECT * FROM tax_provision_line WHERE client_id = ?", (client_id,))
    rate = (qs.get("tax_rate_pct") or [0])[0]
    return tax_advisory_engines.deferred_tax_provision(lines, rate)


# ---- tax computation summary (book-to-taxable reconciliation carry-over + minimum tax)

@route("GET", r"/api/tax/clients/(\d+)/computation-summary")
def tax_get_computation_summary(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [db.current_fy_label()])[0]
    row = db.query("SELECT * FROM tax_computation_summary WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True)
    out = row or {"client_id": client_id, "fy_label": fy}
    out["minimum_tax_check"] = tax_advisory_engines.minimum_tax_check(
        out.get("tax_liability", 0), out.get("turnover"), out.get("minimum_tax_pct"))
    return out


@route("PUT", r"/api/tax/clients/(\d+)/computation-summary")
def tax_put_computation_summary(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    fy = b.get("fy_label") or db.current_fy_label()
    if not fy:
        raise HttpError(400, "fy_label is required")
    fields = ["entity_type", "book_profit", "total_adjustments", "taxable_income", "tax_rate_pct",
              "tax_liability", "turnover", "minimum_tax_pct", "notes"]
    existing = db.query("SELECT id FROM tax_computation_summary WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True)
    cols = [f for f in fields if f in b]
    if existing:
        if cols:
            db.execute(f"UPDATE tax_computation_summary SET {', '.join(c + ' = ?' for c in cols)}, updated_at = ? WHERE id = ?",
                       [b[c] for c in cols] + [db.now_iso(), existing["id"]])
    else:
        all_cols = ["client_id", "fy_label"] + cols
        db.execute(f"INSERT INTO tax_computation_summary ({', '.join(all_cols)}) VALUES ({', '.join('?' for _ in all_cols)})",
                   [client_id, fy] + [b[c] for c in cols])
    row = db.query("SELECT * FROM tax_computation_summary WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True)
    row["minimum_tax_check"] = tax_advisory_engines.minimum_tax_check(
        row.get("tax_liability", 0), row.get("turnover"), row.get("minimum_tax_pct"))
    return row


# ---- tax deductibility checklist

@route("GET", r"/api/tax/clients/(\d+)/deductibility")
def tax_list_deductibility(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM tax_deductibility_check WHERE client_id = ? ORDER BY fy_label DESC, id", (client_id,))


@route("POST", r"/api/tax/clients/(\d+)/deductibility")
def tax_create_deductibility(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("description") or "").strip():
        raise HttpError(400, "A deductibility check needs a fy_label and a description")
    lid = db.execute(
        "INSERT INTO tax_deductibility_check (client_id, fy_label, description, amount, category, status, reason)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b["description"].strip(), b.get("amount", 0), b.get("category"),
         b.get("status", "under_review"), b.get("reason")))
    return db.query("SELECT * FROM tax_deductibility_check WHERE id = ?", (lid,), one=True)


@route("PUT", r"/api/tax/deductibility/(\d+)")
def tax_update_deductibility(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_deductibility_check WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That deductibility check no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["fy_label", "description", "amount", "category", "status", "reason"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE tax_deductibility_check SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    return db.query("SELECT * FROM tax_deductibility_check WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tax/deductibility/(\d+)")
def tax_delete_deductibility(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_deductibility_check WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That deductibility check no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM tax_deductibility_check WHERE id = ?", (m.group(1),))
    return {"ok": True}


# ---- PE risk

@route("GET", r"/api/tax/clients/(\d+)/pe-risk")
def tax_list_pe_risk(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM tax_pe_risk_factor WHERE client_id = ? ORDER BY id", (client_id,))


@route("POST", r"/api/tax/clients/(\d+)/pe-risk/seed")
def tax_seed_pe_risk(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    existing = {r["factor"] for r in db.query("SELECT factor FROM tax_pe_risk_factor WHERE client_id = ?", (client_id,))}
    added = 0
    for factor in TAX_PE_FACTOR_TEMPLATE:
        if factor in existing:
            continue
        db.execute("INSERT INTO tax_pe_risk_factor (client_id, factor) VALUES (?,?)", (client_id, factor))
        added += 1
    return {"added": added}


@route("POST", r"/api/tax/clients/(\d+)/pe-risk")
def tax_create_pe_risk(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("factor") or "").strip():
        raise HttpError(400, "A PE risk factor needs a factor description")
    lid = db.execute(
        "INSERT INTO tax_pe_risk_factor (client_id, factor, present, details, risk_level) VALUES (?,?,?,?,?)",
        (client_id, b["factor"].strip(), 1 if b.get("present") else 0, b.get("details"), b.get("risk_level", "Low")))
    return db.query("SELECT * FROM tax_pe_risk_factor WHERE id = ?", (lid,), one=True)


@route("PUT", r"/api/tax/pe-risk/(\d+)")
def tax_update_pe_risk(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_pe_risk_factor WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That PE risk factor no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["factor", "present", "details", "risk_level"]
    cols = [f for f in fields if f in b]
    if cols:
        vals = [(1 if b[c] else 0) if c == "present" else b[c] for c in cols]
        db.execute(f"UPDATE tax_pe_risk_factor SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?", vals + [m.group(1)])
    return db.query("SELECT * FROM tax_pe_risk_factor WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tax/pe-risk/(\d+)")
def tax_delete_pe_risk(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_pe_risk_factor WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That PE risk factor no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM tax_pe_risk_factor WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/tax/clients/(\d+)/pe-risk-assessment")
def tax_pe_risk_assessment(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    factors = db.query("SELECT * FROM tax_pe_risk_factor WHERE client_id = ?", (client_id,))
    return tax_advisory_engines.pe_risk_assessment(factors)


# ---- tax cases (notice / assessment / appeal) and issues

@route("GET", r"/api/tax/clients/(\d+)/cases")
def tax_list_cases(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM tax_case WHERE client_id = ? ORDER BY status = 'open' DESC, id DESC", (client_id,))


@route("POST", r"/api/tax/clients/(\d+)/cases")
def tax_create_case(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("subject") or "").strip():
        raise HttpError(400, "A case needs a subject")
    cid = db.execute(
        "INSERT INTO tax_case (client_id, case_type, reference_no, authority, fy_label, subject, amount_demanded,"
        " stage, due_date, notes, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
        (client_id, b.get("case_type", "notice"), b.get("reference_no"), b.get("authority"), b.get("fy_label"),
         b["subject"].strip(), b.get("amount_demanded"), b.get("stage", "notice_received"), b.get("due_date"),
         b.get("notes"), u["id"]))
    return db.query("SELECT * FROM tax_case WHERE id = ?", (cid,), one=True)


@route("PUT", r"/api/tax/cases/(\d+)")
def tax_update_case(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_case WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That case no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["case_type", "reference_no", "authority", "fy_label", "subject", "amount_demanded", "stage",
              "status", "due_date", "response_text", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE tax_case SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    return db.query("SELECT * FROM tax_case WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tax/cases/(\d+)")
def tax_delete_case(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_case WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That case no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM tax_case WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/tax/cases/(\d+)/issues")
def tax_list_case_issues(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    case = db.query("SELECT client_id FROM tax_case WHERE id = ?", (m.group(1),), one=True)
    if not case:
        raise HttpError(404, "That case no longer exists")
    _assert_client_visible(u, case["client_id"])
    return db.query("SELECT * FROM tax_case_issue WHERE case_id = ? ORDER BY id", (m.group(1),))


@route("POST", r"/api/tax/cases/(\d+)/issues")
def tax_create_case_issue(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    case = db.query("SELECT client_id FROM tax_case WHERE id = ?", (m.group(1),), one=True)
    if not case:
        raise HttpError(404, "That case no longer exists")
    _assert_client_visible(u, case["client_id"])
    b = _json_body(env)
    if not (b.get("issue_description") or "").strip():
        raise HttpError(400, "An issue needs a description")
    iid = db.execute(
        "INSERT INTO tax_case_issue (case_id, client_id, issue_description, our_position, department_position,"
        " amount_involved, likelihood) VALUES (?,?,?,?,?,?,?)",
        (m.group(1), case["client_id"], b["issue_description"].strip(), b.get("our_position"),
         b.get("department_position"), b.get("amount_involved"), b.get("likelihood", "uncertain")))
    return db.query("SELECT * FROM tax_case_issue WHERE id = ?", (iid,), one=True)


@route("PUT", r"/api/tax/case-issues/(\d+)")
def tax_update_case_issue(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_case_issue WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That issue no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["issue_description", "our_position", "department_position", "amount_involved", "likelihood", "status"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE tax_case_issue SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    return db.query("SELECT * FROM tax_case_issue WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/tax/case-issues/(\d+)")
def tax_delete_case_issue(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_case_issue WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That issue no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM tax_case_issue WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("POST", r"/api/tax/cases/(\d+)/response-document")
def tax_case_response_document(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    case = db.query("SELECT * FROM tax_case WHERE id = ?", (m.group(1),), one=True)
    if not case:
        raise HttpError(404, "That case no longer exists")
    _assert_client_visible(u, case["client_id"])
    client = _tax_client(case["client_id"])
    issues = db.query("SELECT * FROM tax_case_issue WHERE case_id = ?", (m.group(1),))
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": client["name"] if client else "", "fy": case.get("fy_label") or "", "unverified": [],
            "title": "Tax Notice Response"}
    result = {"case": case, "client": dict(client) if client else {}, "issues": issues}
    try:
        path, filename = reports.build("tax_advisory", "docx_case_response", result, meta, REPORT_DIR)
    except ValueError as e:
        raise HttpError(400, str(e))
    rid = db.execute(
        "INSERT INTO report (module_key, format, filename, storage_path, params, status, generated_by, byte_size)"
        " VALUES (?,?,?,?,?,?,?,?)",
        ("tax_advisory", "docx", filename, path, json.dumps({"case_id": case["id"]}),
         "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "report", rid, "tax_advisory.case_response.docx")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download", "byte_size": os.path.getsize(path)}


# ---- working papers

@route("GET", r"/api/tax/clients/(\d+)/working-papers")
def tax_list_working_papers(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM tax_working_paper WHERE client_id = ? ORDER BY ref", (client_id,))


@route("POST", r"/api/tax/clients/(\d+)/working-papers")
def tax_create_working_paper(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("ref") or "").strip():
        raise HttpError(400, "A working paper needs a reference")
    wid = db.execute(
        "INSERT INTO tax_working_paper (client_id, case_id, ref, title, content, prepared_by, prepared_at)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b.get("case_id"), b["ref"].strip(), b.get("title"), b.get("content"), u["id"], db.now_iso()))
    return db.query("SELECT * FROM tax_working_paper WHERE id = ?", (wid,), one=True)


@route("PUT", r"/api/tax/working-papers/(\d+)")
def tax_update_working_paper(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    row = db.query("SELECT client_id FROM tax_working_paper WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That working paper no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["ref", "title", "content", "review_status", "review_comment"]
    cols = [f for f in fields if f in b]
    vals = [b[c] for c in cols]
    if "review_status" in b and b["review_status"] in ("approved", "rejected", "sent_back"):
        cols += ["reviewed_by", "reviewed_at"]; vals += [u["id"], db.now_iso()]
    if cols:
        db.execute(f"UPDATE tax_working_paper SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   vals + [m.group(1)])
    return db.query("SELECT * FROM tax_working_paper WHERE id = ?", (m.group(1),), one=True)


# ---- dashboard & the tax audit support pack

@route("GET", r"/api/tax/clients/(\d+)/dashboard")
def tax_dashboard(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    tds_lines = db.query("SELECT * FROM tax_tds_reconciliation_line WHERE client_id = ?", (client_id,))
    pe_factors = db.query("SELECT * FROM tax_pe_risk_factor WHERE client_id = ?", (client_id,))
    open_cases = db.query("SELECT COUNT(*) n FROM tax_case WHERE client_id = ? AND status = 'open'", (client_id,), one=True)
    deduct_under_review = db.query(
        "SELECT COUNT(*) n FROM tax_deductibility_check WHERE client_id = ? AND status = 'under_review'", (client_id,), one=True)
    return {
        "tds_reconciliation": tax_advisory_engines.tds_reconciliation(tds_lines),
        "pe_risk": tax_advisory_engines.pe_risk_assessment(pe_factors),
        "open_cases": open_cases["n"] if open_cases else 0,
        "deductibility_under_review": deduct_under_review["n"] if deduct_under_review else 0,
    }


@route("POST", r"/api/tax/clients/(\d+)/reports")
def tax_generate_report(env, m):
    u = _user(env)
    _require_module(u, TAX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    fmt = b.get("format", "docx")
    fy = b.get("fy_label") or db.current_fy_label()
    client = _tax_client(client_id)
    summary = db.query("SELECT * FROM tax_computation_summary WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True) or {}
    provision_lines = db.query("SELECT * FROM tax_provision_line WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    deductibility = db.query("SELECT * FROM tax_deductibility_check WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    tds_lines = db.query("SELECT * FROM tax_tds_reconciliation_line WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    sales_lines = db.query("SELECT * FROM tax_vat_sales_line WHERE client_id = ?", (client_id,))
    purchase_lines = db.query("SELECT * FROM tax_vat_purchase_line WHERE client_id = ?", (client_id,))
    result = {
        "client": dict(client) if client else {}, "fy_label": fy, "summary": summary,
        "minimum_tax_check": tax_advisory_engines.minimum_tax_check(
            summary.get("tax_liability", 0), summary.get("turnover"), summary.get("minimum_tax_pct")),
        "deferred_tax": tax_advisory_engines.deferred_tax_provision(provision_lines, summary.get("tax_rate_pct", 0)),
        "deductibility": deductibility,
        "tds_reconciliation": tax_advisory_engines.tds_reconciliation(tds_lines),
        "vat_sales_reconciliation": tax_advisory_engines.sales_vat_reconciliation(sales_lines),
        "vat_purchases_reconciliation": tax_advisory_engines.purchase_vat_reconciliation(purchase_lines),
        "unverified": [],
    }
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": client["name"] if client else "", "fy": fy, "unverified": [],
            "title": reports.TITLES.get("tax_advisory", "Tax advisory")}
    try:
        path, filename = reports.build("tax_advisory", f"{fmt}_audit_pack", result, meta, REPORT_DIR)
    except ValueError as e:
        raise HttpError(400, str(e))
    rid = db.execute(
        "INSERT INTO report (module_key, format, filename, storage_path, params, status, generated_by, byte_size)"
        " VALUES (?,?,?,?,?,?,?,?)",
        ("tax_advisory", fmt, filename, path, json.dumps({"client_id": client_id, "fy_label": fy}),
         "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "report", rid, f"tax_advisory.audit_pack.{fmt}")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download", "byte_size": os.path.getsize(path)}


# --------------------------------------------------------------- audit execution
AUDX_MODULE = "audit_execution"
AUDX_TEST_AREAS = ("Bank", "Debtors", "Creditors", "Inventory", "FixedAssets", "CWIP", "Revenue", "Expense",
                   "Payroll", "Vouching", "Invoice", "CutOff", "Other")
AUDX_CHECKLIST_AREAS = ("Subsequent Events", "NFRS Compliance", "FS Disclosure", "Completion")
AUDX_CHECKLIST_TEMPLATES = {
    "Subsequent Events": [
        "Minutes of board/management meetings after year-end reviewed", "Latest available interim financial information reviewed",
        "Enquiry made of management regarding events after year-end", "Legal counsel enquiry updated for post-year-end matters",
        "Major asset acquisitions/disposals after year-end identified", "Significant litigation developments after year-end identified",
        "Going-concern-relevant events after year-end considered", "Subsequent events review extended to the report date",
    ],
    "NFRS Compliance": [
        "NFRS 15 — revenue recognition 5-step model applied and documented", "NAS 2 — inventory at lower of cost and NRV",
        "NAS 16 — PPE recognition, componentisation and useful lives", "NFRS 9 — expected credit loss model on receivables",
        "NAS 37 — provisions recognised only for a present obligation", "NAS 19 — employee benefits measured actuarially",
        "NFRS 16 — right-of-use assets and lease liabilities recognised", "NAS 12 — deferred tax on the full temporary-difference basis",
        "NAS 24 — related-party transactions identified and disclosed", "NAS 1 — presentation and going-concern basis appropriate",
    ],
    "FS Disclosure": [
        "Accounting policies disclosed", "Related-party transactions and balances disclosed",
        "Contingent liabilities and commitments disclosed", "Capital commitments disclosed",
        "Subsequent events disclosed where material", "Segment information disclosed (if applicable)",
        "Financial instrument risk disclosures included", "Comparative figures presented and consistent",
        "Earnings per share disclosed (if applicable)", "Going-concern basis / material uncertainty disclosed where relevant",
    ],
    "Completion": [
        "All planned procedures completed and cleared", "Outstanding review points cleared",
        "Management representation letter obtained", "Subsequent events review completed",
        "Going-concern assessment completed", "Disclosure checklist completed",
        "Audit differences summarised and evaluated", "Partner review completed and signed off",
        "Working papers referenced and filed", "Report and management letter issued",
    ],
}


def _audx_client(client_id):
    return db.query("SELECT * FROM client WHERE id = ?", (client_id,), one=True)


# ---- sampling selection

@route("GET", r"/api/audx/clients/(\d+)/samples")
def audx_list_samples(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM aud_sample_selection WHERE client_id = ? ORDER BY fy_label DESC, id", (client_id,))


@route("POST", r"/api/audx/clients/(\d+)/samples")
def audx_create_sample(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("area") or "").strip():
        raise HttpError(400, "A sample selection needs a fy_label and an area")
    plan = None
    if b.get("method") == "systematic":
        plan = audit_execution_engines.systematic_sample(b.get("population_size", 0), b.get("sample_size", 0), b.get("start_point", 1))
    sid = db.execute(
        "INSERT INTO aud_sample_selection (client_id, fy_label, area, population_description, population_size,"
        " population_value, method, sample_size, start_point, selected_items_note, notes, created_by)"
        " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b["area"].strip(), b.get("population_description"), b.get("population_size", 0),
         b.get("population_value"), b.get("method", "systematic"), b.get("sample_size", 0), b.get("start_point"),
         (b.get("selected_items_note") or (json.dumps(plan["selected_positions"]) if plan else None)),
         b.get("notes"), u["id"]))
    row = db.query("SELECT * FROM aud_sample_selection WHERE id = ?", (sid,), one=True)
    if plan:
        row["plan"] = plan
    return row


@route("PUT", r"/api/audx/samples/(\d+)")
def audx_update_sample(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    row = db.query("SELECT client_id FROM aud_sample_selection WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That sample selection no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["fy_label", "area", "population_description", "population_size", "population_value", "method",
              "sample_size", "start_point", "selected_items_note", "notes"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE aud_sample_selection SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    return db.query("SELECT * FROM aud_sample_selection WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/audx/samples/(\d+)")
def audx_delete_sample(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    row = db.query("SELECT client_id FROM aud_sample_selection WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That sample selection no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM aud_sample_selection WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("POST", r"/api/audx/sampling-plan")
def audx_sampling_plan_preview(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    b = _json_body(env)
    return audit_execution_engines.systematic_sample(b.get("population_size", 0), b.get("sample_size", 0), b.get("start_point", 1))


# ---- test items (vouching / confirmation / reconciliation / verification / cut-off / substantive testing)

@route("GET", r"/api/audx/clients/(\d+)/test-items")
def audx_list_test_items(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    area = (qs.get("area") or [None])[0]
    sql = "SELECT * FROM aud_test_item WHERE client_id = ?"
    args = [client_id]
    if area:
        sql += " AND area = ?"; args.append(area)
    rows = db.query(sql + " ORDER BY fy_label DESC, area, id", args)
    for r in rows:
        r["detail"] = json.loads(r["detail_json"]) if r.get("detail_json") else {}
    return rows


@route("POST", r"/api/audx/clients/(\d+)/test-items")
def audx_create_test_item(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or b.get("area") not in AUDX_TEST_AREAS:
        raise HttpError(400, "A test item needs a fy_label and a valid area")
    tid = db.execute(
        "INSERT INTO aud_test_item (client_id, fy_label, area, test_type, sample_id, item_ref, description,"
        " book_amount, tested_amount, confirmation_status, status, detail_json, remark, created_by)"
        " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b["area"], b.get("test_type", "vouching"), b.get("sample_id"),
         b.get("item_ref"), b.get("description"), b.get("book_amount", 0), b.get("tested_amount"),
         b.get("confirmation_status"), b.get("status", "open"), json.dumps(b.get("detail") or {}),
         b.get("remark"), u["id"]))
    row = db.query("SELECT * FROM aud_test_item WHERE id = ?", (tid,), one=True)
    row["detail"] = json.loads(row["detail_json"]) if row.get("detail_json") else {}
    return row


@route("PUT", r"/api/audx/test-items/(\d+)")
def audx_update_test_item(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    row = db.query("SELECT client_id FROM aud_test_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That test item no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["fy_label", "area", "test_type", "sample_id", "item_ref", "description", "book_amount",
              "tested_amount", "confirmation_status", "status", "remark"]
    cols = [f for f in fields if f in b]
    vals = [b[c] for c in cols]
    if "detail" in b:
        cols.append("detail_json"); vals.append(json.dumps(b["detail"] or {}))
    if cols:
        db.execute(f"UPDATE aud_test_item SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?", vals + [m.group(1)])
    out = db.query("SELECT * FROM aud_test_item WHERE id = ?", (m.group(1),), one=True)
    out["detail"] = json.loads(out["detail_json"]) if out.get("detail_json") else {}
    return out


@route("DELETE", r"/api/audx/test-items/(\d+)")
def audx_delete_test_item(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    row = db.query("SELECT client_id FROM aud_test_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That test item no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM aud_test_item WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/audx/clients/(\d+)/test-item-summary")
def audx_test_item_summary(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [None])[0]
    sql = "SELECT * FROM aud_test_item WHERE client_id = ?"
    args = [client_id]
    if fy:
        sql += " AND fy_label = ?"; args.append(fy)
    items = db.query(sql, args)
    return audit_execution_engines.test_item_summary(items)


# ---- analytical review

@route("GET", r"/api/audx/clients/(\d+)/analytical")
def audx_list_analytical(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM aud_analytical_procedure WHERE client_id = ? ORDER BY fy_label DESC, id", (client_id,))


@route("POST", r"/api/audx/clients/(\d+)/analytical")
def audx_create_analytical(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("metric") or "").strip():
        raise HttpError(400, "An analytical procedure needs a fy_label and a metric")
    aid = db.execute(
        "INSERT INTO aud_analytical_procedure (client_id, fy_label, metric, prior_period_value, current_period_value,"
        " expected_value, threshold_pct, explanation) VALUES (?,?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b["metric"].strip(), b.get("prior_period_value"), b.get("current_period_value", 0),
         b.get("expected_value"), b.get("threshold_pct", 10), b.get("explanation")))
    return db.query("SELECT * FROM aud_analytical_procedure WHERE id = ?", (aid,), one=True)


@route("PUT", r"/api/audx/analytical/(\d+)")
def audx_update_analytical(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    row = db.query("SELECT client_id FROM aud_analytical_procedure WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That analytical procedure no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["fy_label", "metric", "prior_period_value", "current_period_value", "expected_value", "threshold_pct", "explanation"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE aud_analytical_procedure SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [b[c] for c in cols] + [m.group(1)])
    return db.query("SELECT * FROM aud_analytical_procedure WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/audx/analytical/(\d+)")
def audx_delete_analytical(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    row = db.query("SELECT client_id FROM aud_analytical_procedure WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That analytical procedure no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM aud_analytical_procedure WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/audx/clients/(\d+)/analytical-review")
def audx_analytical_review(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [None])[0]
    sql = "SELECT * FROM aud_analytical_procedure WHERE client_id = ?"
    args = [client_id]
    if fy:
        sql += " AND fy_label = ?"; args.append(fy)
    procedures = db.query(sql, args)
    return audit_execution_engines.analytical_review(procedures)


# ---- going concern

@route("GET", r"/api/audx/clients/(\d+)/going-concern-indicators")
def audx_list_gc_indicators(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM aud_going_concern_indicator WHERE client_id = ? ORDER BY fy_label DESC, id", (client_id,))


@route("POST", r"/api/audx/clients/(\d+)/going-concern-indicators")
def audx_create_gc_indicator(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("indicator") or "").strip():
        raise HttpError(400, "A going-concern indicator needs a fy_label and an indicator")
    gid = db.execute(
        "INSERT INTO aud_going_concern_indicator (client_id, fy_label, category, indicator, present, details, mitigating_factor)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b.get("category"), b["indicator"].strip(), 1 if b.get("present") else 0,
         b.get("details"), b.get("mitigating_factor")))
    return db.query("SELECT * FROM aud_going_concern_indicator WHERE id = ?", (gid,), one=True)


@route("PUT", r"/api/audx/going-concern-indicators/(\d+)")
def audx_update_gc_indicator(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    row = db.query("SELECT client_id FROM aud_going_concern_indicator WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That indicator no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["fy_label", "category", "indicator", "present", "details", "mitigating_factor"]
    cols = [f for f in fields if f in b]
    if cols:
        vals = [(1 if b[c] else 0) if c == "present" else b[c] for c in cols]
        db.execute(f"UPDATE aud_going_concern_indicator SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?", vals + [m.group(1)])
    return db.query("SELECT * FROM aud_going_concern_indicator WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/audx/going-concern-indicators/(\d+)")
def audx_delete_gc_indicator(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    row = db.query("SELECT client_id FROM aud_going_concern_indicator WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That indicator no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM aud_going_concern_indicator WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/audx/clients/(\d+)/going-concern-assessment")
def audx_get_gc_assessment(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [db.current_fy_label()])[0]
    indicators = db.query("SELECT * FROM aud_going_concern_indicator WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    row = db.query("SELECT * FROM aud_going_concern_assessment WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True)
    out = row or {"client_id": client_id, "fy_label": fy}
    out["summary"] = audit_execution_engines.going_concern_summary(indicators)
    return out


@route("PUT", r"/api/audx/clients/(\d+)/going-concern-assessment")
def audx_put_gc_assessment(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    fy = b.get("fy_label") or db.current_fy_label()
    if not fy:
        raise HttpError(400, "fy_label is required")
    fields = ["conclusion", "material_uncertainty_exists", "notes"]
    existing = db.query("SELECT id FROM aud_going_concern_assessment WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True)
    cols = [f for f in fields if f in b]
    if existing:
        if cols:
            vals = [(1 if b[c] else 0) if c == "material_uncertainty_exists" else b[c] for c in cols]
            db.execute(f"UPDATE aud_going_concern_assessment SET {', '.join(c + ' = ?' for c in cols)}, updated_at = ? WHERE id = ?",
                       vals + [db.now_iso(), existing["id"]])
    else:
        vals = [(1 if b[c] else 0) if c == "material_uncertainty_exists" else b[c] for c in cols]
        all_cols = ["client_id", "fy_label"] + cols
        db.execute(f"INSERT INTO aud_going_concern_assessment ({', '.join(all_cols)}) VALUES ({', '.join('?' for _ in all_cols)})",
                   [client_id, fy] + vals)
    row = db.query("SELECT * FROM aud_going_concern_assessment WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True)
    indicators = db.query("SELECT * FROM aud_going_concern_indicator WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    row["summary"] = audit_execution_engines.going_concern_summary(indicators)
    return row


# ---- checklists (subsequent events / NFRS compliance / FS disclosure / completion)

@route("GET", r"/api/audx/clients/(\d+)/checklist")
def audx_list_checklist(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    area = (qs.get("area") or [None])[0]
    fy = (qs.get("fy_label") or [None])[0]
    sql = "SELECT * FROM aud_checklist_item WHERE client_id = ?"
    args = [client_id]
    if area:
        sql += " AND area = ?"; args.append(area)
    if fy:
        sql += " AND fy_label = ?"; args.append(fy)
    return db.query(sql + " ORDER BY area, id", args)


@route("POST", r"/api/audx/clients/(\d+)/checklist")
def audx_create_checklist_item(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if b.get("area") not in AUDX_CHECKLIST_AREAS or not b.get("fy_label") or not (b.get("criterion") or "").strip():
        raise HttpError(400, "A checklist item needs a fy_label, a valid area and a criterion")
    cid = db.execute(
        "INSERT INTO aud_checklist_item (client_id, fy_label, area, criterion, evidence, remark, created_by)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b["area"], b["criterion"].strip(), b.get("evidence"), b.get("remark"), u["id"]))
    return db.query("SELECT * FROM aud_checklist_item WHERE id = ?", (cid,), one=True)


@route("POST", r"/api/audx/clients/(\d+)/checklist/seed")
def audx_seed_checklist(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    area = b.get("area")
    fy = b.get("fy_label") or db.current_fy_label()
    if area not in AUDX_CHECKLIST_TEMPLATES:
        raise HttpError(400, "Unknown area")
    existing = {r["criterion"] for r in db.query(
        "SELECT criterion FROM aud_checklist_item WHERE client_id = ? AND area = ? AND fy_label = ?", (client_id, area, fy))}
    added = 0
    for criterion in AUDX_CHECKLIST_TEMPLATES[area]:
        if criterion in existing:
            continue
        db.execute("INSERT INTO aud_checklist_item (client_id, fy_label, area, criterion, created_by) VALUES (?,?,?,?,?)",
                   (client_id, fy, area, criterion, u["id"]))
        added += 1
    return {"added": added}


@route("PUT", r"/api/audx/checklist/(\d+)")
def audx_update_checklist_item(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    row = db.query("SELECT client_id FROM aud_checklist_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That checklist item no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["criterion", "status", "evidence", "remark"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE aud_checklist_item SET {', '.join(c + ' = ?' for c in cols)}, updated_at = ? WHERE id = ?",
                   [b[c] for c in cols] + [db.now_iso(), m.group(1)])
    return db.query("SELECT * FROM aud_checklist_item WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/audx/checklist/(\d+)")
def audx_delete_checklist_item(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    row = db.query("SELECT client_id FROM aud_checklist_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That checklist item no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute("DELETE FROM aud_checklist_item WHERE id = ?", (m.group(1),))
    return {"ok": True}


@route("GET", r"/api/audx/clients/(\d+)/checklist-summary")
def audx_checklist_summary(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [None])[0]
    sql = "SELECT * FROM aud_checklist_item WHERE client_id = ?"
    args = [client_id]
    if fy:
        sql += " AND fy_label = ?"; args.append(fy)
    items = db.query(sql, args)
    return audit_execution_engines.checklist_summary(items)


# ---- working papers

@route("GET", r"/api/audx/clients/(\d+)/working-papers")
def audx_list_working_papers(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM aud_working_paper WHERE client_id = ? ORDER BY ref", (client_id,))


@route("POST", r"/api/audx/clients/(\d+)/working-papers")
def audx_create_working_paper(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("ref") or "").strip():
        raise HttpError(400, "A working paper needs a reference")
    wid = db.execute(
        "INSERT INTO aud_working_paper (client_id, fy_label, ref, title, content, prepared_by, prepared_at)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b.get("fy_label"), b["ref"].strip(), b.get("title"), b.get("content"), u["id"], db.now_iso()))
    return db.query("SELECT * FROM aud_working_paper WHERE id = ?", (wid,), one=True)


@route("PUT", r"/api/audx/working-papers/(\d+)")
def audx_update_working_paper(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    row = db.query("SELECT client_id FROM aud_working_paper WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That working paper no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["ref", "title", "content", "review_status", "review_comment"]
    cols = [f for f in fields if f in b]
    vals = [b[c] for c in cols]
    if "review_status" in b and b["review_status"] in ("approved", "rejected", "sent_back"):
        cols += ["reviewed_by", "reviewed_at"]; vals += [u["id"], db.now_iso()]
    if cols:
        db.execute(f"UPDATE aud_working_paper SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   vals + [m.group(1)])
    return db.query("SELECT * FROM aud_working_paper WHERE id = ?", (m.group(1),), one=True)


# ---- dashboard & reports (audit finding = internal_audit's own findings register, not duplicated here;
# the management letter below pulls this module's own test exceptions and going-concern conclusion instead
# of silently reading another module's workfile)

@route("GET", r"/api/audx/clients/(\d+)/dashboard")
def audx_dashboard(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    items = db.query("SELECT * FROM aud_test_item WHERE client_id = ?", (client_id,))
    procedures = db.query("SELECT * FROM aud_analytical_procedure WHERE client_id = ?", (client_id,))
    indicators = db.query("SELECT * FROM aud_going_concern_indicator WHERE client_id = ?", (client_id,))
    checklist = db.query("SELECT * FROM aud_checklist_item WHERE client_id = ?", (client_id,))
    return {
        "test_items": audit_execution_engines.test_item_summary(items),
        "analytical": audit_execution_engines.analytical_review(procedures),
        "going_concern": audit_execution_engines.going_concern_summary(indicators),
        "checklist": audit_execution_engines.checklist_summary(checklist),
    }


@route("POST", r"/api/audx/clients/(\d+)/reports")
def audx_generate_report(env, m):
    u = _user(env)
    _require_module(u, AUDX_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    report_type = b.get("report_type", "management_letter")
    fmt = b.get("format", "docx")
    fy = b.get("fy_label") or db.current_fy_label()
    client = _audx_client(client_id)
    items = db.query("SELECT * FROM aud_test_item WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    procedures = db.query("SELECT * FROM aud_analytical_procedure WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    indicators = db.query("SELECT * FROM aud_going_concern_indicator WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    gc_assessment = db.query("SELECT * FROM aud_going_concern_assessment WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True) or {}
    checklist = db.query("SELECT * FROM aud_checklist_item WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    exceptions = [i for i in items if i["status"] == "exception"]
    result = {
        "client": dict(client) if client else {}, "fy_label": fy,
        "test_item_summary": audit_execution_engines.test_item_summary(items),
        "exceptions": exceptions,
        "analytical_review": audit_execution_engines.analytical_review(procedures),
        "going_concern_summary": audit_execution_engines.going_concern_summary(indicators),
        "going_concern_assessment": gc_assessment,
        "checklist_summary": audit_execution_engines.checklist_summary(checklist),
        "unverified": [],
    }
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": client["name"] if client else "", "fy": fy, "unverified": [],
            "title": reports.TITLES.get("audit_execution", "Audit execution")}
    try:
        path, filename = reports.build("audit_execution", f"{fmt}_{report_type}", result, meta, REPORT_DIR)
    except ValueError as e:
        raise HttpError(400, str(e))
    rid = db.execute(
        "INSERT INTO report (module_key, format, filename, storage_path, params, status, generated_by, byte_size)"
        " VALUES (?,?,?,?,?,?,?,?)",
        ("audit_execution", fmt, filename, path, json.dumps({"client_id": client_id, "fy_label": fy, "report_type": report_type}),
         "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "report", rid, f"audit_execution.{report_type}.{fmt}")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download", "byte_size": os.path.getsize(path)}


# --------------------------------------------------------------- accounting automation
# Covers the "Accounting & Financial Statement Automators" list not already covered
# by engines.fin_statements (FS Generator / Notes to Accounts / TB-to-FS mapping),
# engines.fixed_assets (Depreciation Schedule) or engines.lease_schedule (Lease
# Accounting, lessee side). TB diagnostics and policy suggestions are stateless —
# the caller sends its own TB rows / FS heads on every call, nothing is read from
# another module's stored data.
ACCT_MODULE = "acct_automation"
ACCT_CLOSING_AREAS = ("Closing",)
ACCT_CLOSING_TEMPLATE = [
    "All accruals and provisions for the year booked", "All prepayments amortised to the reporting date",
    "Depreciation posted for the full year on all asset classes", "Bank reconciliations completed for every account",
    "Inventory count performed and reconciled to the ledger", "Foreign-currency monetary items revalued at the closing rate",
    "Interest accrued on all interest-bearing assets/liabilities", "Intercompany/related-party balances confirmed and matched",
    "Suspense and unclassified balances cleared or explained", "Temporary/nominal accounts closed to profit and loss",
    "Trial balance ties out (debit = credit, opening = last year's closing)", "Financial statements and notes drafted and cross-cast",
]


def _acct_client(client_id):
    return db.query("SELECT * FROM client WHERE id = ?", (client_id,), one=True)


def _acct_crud_update(table, row_id, fields, body):
    row = db.query(f"SELECT client_id FROM {table} WHERE id = ?", (row_id,), one=True)
    if not row:
        raise HttpError(404, "That item no longer exists")
    cols = [f for f in fields if f in body]
    if cols:
        db.execute(f"UPDATE {table} SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   [body[c] for c in cols] + [row_id])
    return row["client_id"]


def _acct_crud_delete(table, row_id, u):
    row = db.query(f"SELECT client_id FROM {table} WHERE id = ?", (row_id,), one=True)
    if not row:
        raise HttpError(404, "That item no longer exists")
    _assert_client_visible(u, row["client_id"])
    db.execute(f"DELETE FROM {table} WHERE id = ?", (row_id,))


# ---- chart of accounts

@route("GET", r"/api/acct/clients/(\d+)/coa")
def acct_list_coa(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM acct_chart_of_accounts WHERE client_id = ? ORDER BY code, name", (client_id,))


@route("POST", r"/api/acct/clients/(\d+)/coa")
def acct_create_coa(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("name") or "").strip():
        raise HttpError(400, "A chart-of-accounts entry needs a name")
    cid = db.execute(
        "INSERT INTO acct_chart_of_accounts (client_id, code, name, fs_head, normal_side, parent_code, notes)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b.get("code"), b["name"].strip(), b.get("fs_head"), b.get("normal_side", "debit"),
         b.get("parent_code"), b.get("notes")))
    return db.query("SELECT * FROM acct_chart_of_accounts WHERE id = ?", (cid,), one=True)


@route("PUT", r"/api/acct/coa/(\d+)")
def acct_update_coa(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    b = _json_body(env)
    if "is_active" in b:
        b["is_active"] = 1 if b["is_active"] else 0
    client_id = _acct_crud_update("acct_chart_of_accounts", m.group(1),
        ["code", "name", "fs_head", "normal_side", "parent_code", "is_active", "notes"], b)
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM acct_chart_of_accounts WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/acct/coa/(\d+)")
def acct_delete_coa(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    _acct_crud_delete("acct_chart_of_accounts", m.group(1), u)
    return {"ok": True}


# ---- TB diagnostics & policy suggestions (stateless — Trial Balance Analyzer,
# Suspense Account Detector, Unusual Balance Detector, Debit/Credit Classification
# Checker, Accounting Policy Generator)

@route("POST", r"/api/acct/tb-diagnostics")
def acct_tb_diagnostics(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    b = _json_body(env)
    head_nature = {h["id"]: h["nat"] for h in engines.HEADS}
    return acct_engines.tb_diagnostics(b.get("rows") or [], head_nature=head_nature,
        materiality=b.get("materiality", 0), movement_threshold_pct=b.get("movement_threshold_pct", 20))


@route("POST", r"/api/acct/policy-suggestions")
def acct_policy_suggestions(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    b = _json_body(env)
    country_code = country_service.firm_country_code(u["firm_id"])
    return {"policies": acct_engines.suggest_policies(b.get("heads_present") or [], country_code)}


# ---- cash flow statement

@route("GET", r"/api/acct/clients/(\d+)/cash-flow")
def acct_get_cash_flow(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [db.current_fy_label()])[0]
    setting = db.query("SELECT * FROM acct_cash_flow_setting WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True) or \
        {"client_id": client_id, "fy_label": fy, "method": "indirect", "opening_cash": 0, "closing_cash_actual": None}
    lines = db.query("SELECT * FROM acct_cash_flow_line WHERE client_id = ? AND fy_label = ? ORDER BY id", (client_id, fy))
    setting["lines"] = lines
    setting["computed"] = acct_engines.cash_flow_statement(lines, setting.get("opening_cash", 0), setting.get("closing_cash_actual"))
    return setting


@route("PUT", r"/api/acct/clients/(\d+)/cash-flow-setting")
def acct_put_cash_flow_setting(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    fy = b.get("fy_label") or db.current_fy_label()
    fields = ["method", "opening_cash", "closing_cash_actual"]
    existing = db.query("SELECT id FROM acct_cash_flow_setting WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True)
    cols = [f for f in fields if f in b]
    if existing:
        if cols:
            db.execute(f"UPDATE acct_cash_flow_setting SET {', '.join(c + ' = ?' for c in cols)}, updated_at = ? WHERE id = ?",
                       [b[c] for c in cols] + [db.now_iso(), existing["id"]])
    else:
        all_cols = ["client_id", "fy_label"] + cols
        db.execute(f"INSERT INTO acct_cash_flow_setting ({', '.join(all_cols)}) VALUES ({', '.join('?' for _ in all_cols)})",
                   [client_id, fy] + [b[c] for c in cols])
    return db.query("SELECT * FROM acct_cash_flow_setting WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True)


@route("POST", r"/api/acct/clients/(\d+)/cash-flow-lines")
def acct_create_cash_flow_line(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("label") or "").strip():
        raise HttpError(400, "A cash flow line needs a fy_label and a label")
    lid = db.execute(
        "INSERT INTO acct_cash_flow_line (client_id, fy_label, section, label, amount, note) VALUES (?,?,?,?,?,?)",
        (client_id, b["fy_label"], b.get("section", "operating_adjustment"), b["label"].strip(), b.get("amount", 0), b.get("note")))
    return db.query("SELECT * FROM acct_cash_flow_line WHERE id = ?", (lid,), one=True)


@route("PUT", r"/api/acct/cash-flow-lines/(\d+)")
def acct_update_cash_flow_line(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    b = _json_body(env)
    client_id = _acct_crud_update("acct_cash_flow_line", m.group(1), ["section", "label", "amount", "note"], b)
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM acct_cash_flow_line WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/acct/cash-flow-lines/(\d+)")
def acct_delete_cash_flow_line(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    _acct_crud_delete("acct_cash_flow_line", m.group(1), u)
    return {"ok": True}


# ---- statement of changes in equity

@route("GET", r"/api/acct/clients/(\d+)/equity")
def acct_list_equity(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [db.current_fy_label()])[0]
    rows = db.query("SELECT * FROM acct_equity_component WHERE client_id = ? AND fy_label = ? ORDER BY id", (client_id, fy))
    return {"fy_label": fy, "components": rows, "computed": acct_engines.equity_movement(rows)}


@route("POST", r"/api/acct/clients/(\d+)/equity")
def acct_create_equity(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("name") or "").strip():
        raise HttpError(400, "An equity component needs a fy_label and a name")
    eid = db.execute(
        "INSERT INTO acct_equity_component (client_id, fy_label, name, opening_balance, profit_for_year, oci,"
        " dividend, share_issued, share_buyback, transfer_in, transfer_out, other_movement, note)"
        " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b["name"].strip(), b.get("opening_balance", 0), b.get("profit_for_year", 0),
         b.get("oci", 0), b.get("dividend", 0), b.get("share_issued", 0), b.get("share_buyback", 0),
         b.get("transfer_in", 0), b.get("transfer_out", 0), b.get("other_movement", 0), b.get("note")))
    return db.query("SELECT * FROM acct_equity_component WHERE id = ?", (eid,), one=True)


@route("PUT", r"/api/acct/equity/(\d+)")
def acct_update_equity(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    b = _json_body(env)
    client_id = _acct_crud_update("acct_equity_component", m.group(1),
        ["name", "opening_balance", "profit_for_year", "oci", "dividend", "share_issued", "share_buyback",
         "transfer_in", "transfer_out", "other_movement", "note"], b)
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM acct_equity_component WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/acct/equity/(\d+)")
def acct_delete_equity(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    _acct_crud_delete("acct_equity_component", m.group(1), u)
    return {"ok": True}


# ---- accrual & provision

@route("GET", r"/api/acct/clients/(\d+)/provisions")
def acct_list_provisions(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [db.current_fy_label()])[0]
    rows = db.query("SELECT * FROM acct_provision WHERE client_id = ? AND fy_label = ? ORDER BY id", (client_id, fy))
    return {"fy_label": fy, "computed": acct_engines.provision_movement(rows)}


@route("POST", r"/api/acct/clients/(\d+)/provisions")
def acct_create_provision(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("item") or "").strip():
        raise HttpError(400, "A provision needs a fy_label and an item name")
    pid = db.execute(
        "INSERT INTO acct_provision (client_id, fy_label, category, item, basis, opening_balance, charge_for_year,"
        " utilized, reversed, note) VALUES (?,?,?,?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b.get("category", "provision"), b["item"].strip(), b.get("basis"),
         b.get("opening_balance", 0), b.get("charge_for_year", 0), b.get("utilized", 0), b.get("reversed", 0), b.get("note")))
    return db.query("SELECT * FROM acct_provision WHERE id = ?", (pid,), one=True)


@route("PUT", r"/api/acct/provisions/(\d+)")
def acct_update_provision(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    b = _json_body(env)
    client_id = _acct_crud_update("acct_provision", m.group(1),
        ["category", "item", "basis", "opening_balance", "charge_for_year", "utilized", "reversed", "note"], b)
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM acct_provision WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/acct/provisions/(\d+)")
def acct_delete_provision(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    _acct_crud_delete("acct_provision", m.group(1), u)
    return {"ok": True}


# ---- prepayment amortization

@route("GET", r"/api/acct/clients/(\d+)/prepayments")
def acct_list_prepayments(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [db.current_fy_label()])[0]
    as_of = (qs.get("as_of") or [None])[0]
    rows = db.query("SELECT * FROM acct_prepayment WHERE client_id = ? AND fy_label = ? ORDER BY id", (client_id, fy))
    return {"fy_label": fy, "as_of": as_of, "rows": rows, "computed": acct_engines.prepayment_amortization(rows, as_of)}


@route("POST", r"/api/acct/clients/(\d+)/prepayments")
def acct_create_prepayment(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("item") or "").strip() or not b.get("start_date") or not b.get("end_date"):
        raise HttpError(400, "A prepayment needs a fy_label, an item name, a start date and an end date")
    pid = db.execute(
        "INSERT INTO acct_prepayment (client_id, fy_label, item, total_amount, start_date, end_date, note) VALUES (?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b["item"].strip(), b.get("total_amount", 0), b["start_date"], b["end_date"], b.get("note")))
    return db.query("SELECT * FROM acct_prepayment WHERE id = ?", (pid,), one=True)


@route("PUT", r"/api/acct/prepayments/(\d+)")
def acct_update_prepayment(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    b = _json_body(env)
    client_id = _acct_crud_update("acct_prepayment", m.group(1), ["item", "total_amount", "start_date", "end_date", "note"], b)
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM acct_prepayment WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/acct/prepayments/(\d+)")
def acct_delete_prepayment(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    _acct_crud_delete("acct_prepayment", m.group(1), u)
    return {"ok": True}


# ---- foreign currency revaluation

@route("GET", r"/api/acct/clients/(\d+)/fx")
def acct_list_fx(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [db.current_fy_label()])[0]
    rows = db.query("SELECT * FROM acct_fx_item WHERE client_id = ? AND fy_label = ? ORDER BY id", (client_id, fy))
    return {"fy_label": fy, "computed": acct_engines.fx_revaluation(rows)}


@route("POST", r"/api/acct/clients/(\d+)/fx")
def acct_create_fx(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("item") or "").strip():
        raise HttpError(400, "An FX item needs a fy_label and an item name")
    fid = db.execute(
        "INSERT INTO acct_fx_item (client_id, fy_label, item, currency, foreign_amount, booked_rate, closing_rate,"
        " classification, note) VALUES (?,?,?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b["item"].strip(), b.get("currency", "USD"), b.get("foreign_amount", 0),
         b.get("booked_rate", 0), b.get("closing_rate", 0), b.get("classification", "asset"), b.get("note")))
    return db.query("SELECT * FROM acct_fx_item WHERE id = ?", (fid,), one=True)


@route("PUT", r"/api/acct/fx/(\d+)")
def acct_update_fx(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    b = _json_body(env)
    client_id = _acct_crud_update("acct_fx_item", m.group(1),
        ["item", "currency", "foreign_amount", "booked_rate", "closing_rate", "classification", "note"], b)
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM acct_fx_item WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/acct/fx/(\d+)")
def acct_delete_fx(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    _acct_crud_delete("acct_fx_item", m.group(1), u)
    return {"ok": True}


# ---- interest accrual (generalised — FDs, intercompany, staff loans; a loan's
# own period interest still lives in engines.loan_schedule)

@route("GET", r"/api/acct/clients/(\d+)/interest-accrual")
def acct_list_interest(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [db.current_fy_label()])[0]
    rows = db.query("SELECT * FROM acct_interest_accrual WHERE client_id = ? AND fy_label = ? ORDER BY id", (client_id, fy))
    return {"fy_label": fy, "computed": acct_engines.interest_accrual(rows)}


@route("POST", r"/api/acct/clients/(\d+)/interest-accrual")
def acct_create_interest(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("item") or "").strip() or not b.get("from_date") or not b.get("to_date"):
        raise HttpError(400, "An interest accrual item needs a fy_label, an item name, a from date and a to date")
    iid = db.execute(
        "INSERT INTO acct_interest_accrual (client_id, fy_label, item, principal, rate_pct, from_date, to_date,"
        " day_count_basis, compounding, note) VALUES (?,?,?,?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b["item"].strip(), b.get("principal", 0), b.get("rate_pct", 0), b["from_date"],
         b["to_date"], b.get("day_count_basis", "365"), b.get("compounding", "simple"), b.get("note")))
    return db.query("SELECT * FROM acct_interest_accrual WHERE id = ?", (iid,), one=True)


@route("PUT", r"/api/acct/interest-accrual/(\d+)")
def acct_update_interest(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    b = _json_body(env)
    client_id = _acct_crud_update("acct_interest_accrual", m.group(1),
        ["item", "principal", "rate_pct", "from_date", "to_date", "day_count_basis", "compounding", "note"], b)
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM acct_interest_accrual WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/acct/interest-accrual/(\d+)")
def acct_delete_interest(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    _acct_crud_delete("acct_interest_accrual", m.group(1), u)
    return {"ok": True}


# ---- borrowing cost capitalization (NAS 23)

@route("GET", r"/api/acct/clients/(\d+)/borrowing-capitalization")
def acct_list_borrowcap(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [db.current_fy_label()])[0]
    rows = db.query("SELECT * FROM acct_borrowing_capitalization WHERE client_id = ? AND fy_label = ? ORDER BY id", (client_id, fy))
    for r in rows:
        specific = json.loads(r["specific_borrowings_json"] or "[]")
        general = json.loads(r["general_borrowings_json"] or "[]")
        r["specific_borrowings"] = specific
        r["general_borrowings"] = general
        r["computed"] = acct_engines.borrowing_cost_capitalization(
            specific, general, r["capitalizable_expenditure_general"], r["period_days"], r["income_from_temp_investment"])
    return rows


@route("POST", r"/api/acct/clients/(\d+)/borrowing-capitalization")
def acct_create_borrowcap(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("qualifying_asset") or "").strip():
        raise HttpError(400, "A capitalization entry needs a fy_label and a qualifying asset name")
    bid = db.execute(
        "INSERT INTO acct_borrowing_capitalization (client_id, fy_label, qualifying_asset, period_days,"
        " specific_borrowings_json, general_borrowings_json, capitalizable_expenditure_general,"
        " income_from_temp_investment, note) VALUES (?,?,?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b["qualifying_asset"].strip(), b.get("period_days", 365),
         json.dumps(b.get("specific_borrowings") or []), json.dumps(b.get("general_borrowings") or []),
         b.get("capitalizable_expenditure_general", 0), b.get("income_from_temp_investment", 0), b.get("note")))
    row = db.query("SELECT * FROM acct_borrowing_capitalization WHERE id = ?", (bid,), one=True)
    row["specific_borrowings"] = json.loads(row["specific_borrowings_json"] or "[]")
    row["general_borrowings"] = json.loads(row["general_borrowings_json"] or "[]")
    row["computed"] = acct_engines.borrowing_cost_capitalization(
        row["specific_borrowings"], row["general_borrowings"], row["capitalizable_expenditure_general"],
        row["period_days"], row["income_from_temp_investment"])
    return row


@route("PUT", r"/api/acct/borrowing-capitalization/(\d+)")
def acct_update_borrowcap(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    b = _json_body(env)
    if "specific_borrowings" in b:
        b["specific_borrowings_json"] = json.dumps(b.pop("specific_borrowings"))
    if "general_borrowings" in b:
        b["general_borrowings_json"] = json.dumps(b.pop("general_borrowings"))
    client_id = _acct_crud_update("acct_borrowing_capitalization", m.group(1),
        ["qualifying_asset", "period_days", "specific_borrowings_json", "general_borrowings_json",
         "capitalizable_expenditure_general", "income_from_temp_investment", "note"], b)
    _assert_client_visible(u, client_id)
    row = db.query("SELECT * FROM acct_borrowing_capitalization WHERE id = ?", (m.group(1),), one=True)
    row["specific_borrowings"] = json.loads(row["specific_borrowings_json"] or "[]")
    row["general_borrowings"] = json.loads(row["general_borrowings_json"] or "[]")
    row["computed"] = acct_engines.borrowing_cost_capitalization(
        row["specific_borrowings"], row["general_borrowings"], row["capitalizable_expenditure_general"],
        row["period_days"], row["income_from_temp_investment"])
    return row


@route("DELETE", r"/api/acct/borrowing-capitalization/(\d+)")
def acct_delete_borrowcap(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    _acct_crud_delete("acct_borrowing_capitalization", m.group(1), u)
    return {"ok": True}


# ---- year-end closing checklist

@route("GET", r"/api/acct/clients/(\d+)/closing-checklist")
def acct_list_closing_checklist(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [None])[0]
    sql = "SELECT * FROM acct_closing_checklist_item WHERE client_id = ?"
    args = [client_id]
    if fy:
        sql += " AND fy_label = ?"; args.append(fy)
    return db.query(sql + " ORDER BY id", args)


@route("POST", r"/api/acct/clients/(\d+)/closing-checklist")
def acct_create_closing_checklist_item(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not b.get("fy_label") or not (b.get("criterion") or "").strip():
        raise HttpError(400, "A closing checklist item needs a fy_label and a criterion")
    cid = db.execute(
        "INSERT INTO acct_closing_checklist_item (client_id, fy_label, area, criterion, evidence, remark, created_by)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b["fy_label"], b.get("area", "Closing"), b["criterion"].strip(), b.get("evidence"), b.get("remark"), u["id"]))
    return db.query("SELECT * FROM acct_closing_checklist_item WHERE id = ?", (cid,), one=True)


@route("POST", r"/api/acct/clients/(\d+)/closing-checklist/seed")
def acct_seed_closing_checklist(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    fy = b.get("fy_label") or db.current_fy_label()
    existing = {r["criterion"] for r in db.query(
        "SELECT criterion FROM acct_closing_checklist_item WHERE client_id = ? AND area = 'Closing' AND fy_label = ?", (client_id, fy))}
    added = 0
    for criterion in ACCT_CLOSING_TEMPLATE:
        if criterion in existing:
            continue
        db.execute("INSERT INTO acct_closing_checklist_item (client_id, fy_label, area, criterion, created_by) VALUES (?,?,?,?,?)",
                   (client_id, fy, "Closing", criterion, u["id"]))
        added += 1
    return {"added": added}


@route("PUT", r"/api/acct/closing-checklist/(\d+)")
def acct_update_closing_checklist_item(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    row = db.query("SELECT client_id FROM acct_closing_checklist_item WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That checklist item no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["criterion", "status", "evidence", "remark"]
    cols = [f for f in fields if f in b]
    if cols:
        db.execute(f"UPDATE acct_closing_checklist_item SET {', '.join(c + ' = ?' for c in cols)}, updated_at = ? WHERE id = ?",
                   [b[c] for c in cols] + [db.now_iso(), m.group(1)])
    return db.query("SELECT * FROM acct_closing_checklist_item WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/acct/closing-checklist/(\d+)")
def acct_delete_closing_checklist_item(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    _acct_crud_delete("acct_closing_checklist_item", m.group(1), u)
    return {"ok": True}


@route("GET", r"/api/acct/clients/(\d+)/closing-checklist-summary")
def acct_closing_checklist_summary(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [None])[0]
    sql = "SELECT * FROM acct_closing_checklist_item WHERE client_id = ?"
    args = [client_id]
    if fy:
        sql += " AND fy_label = ?"; args.append(fy)
    items = db.query(sql, args)
    return acct_engines.checklist_summary(items)


# ---- working papers

@route("GET", r"/api/acct/clients/(\d+)/working-papers")
def acct_list_working_papers(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    return db.query("SELECT * FROM acct_working_paper WHERE client_id = ? ORDER BY ref", (client_id,))


@route("POST", r"/api/acct/clients/(\d+)/working-papers")
def acct_create_working_paper(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    if not (b.get("ref") or "").strip():
        raise HttpError(400, "A working paper needs a reference")
    wid = db.execute(
        "INSERT INTO acct_working_paper (client_id, fy_label, ref, title, content, prepared_by, prepared_at)"
        " VALUES (?,?,?,?,?,?,?)",
        (client_id, b.get("fy_label"), b["ref"].strip(), b.get("title"), b.get("content"), u["id"], db.now_iso()))
    return db.query("SELECT * FROM acct_working_paper WHERE id = ?", (wid,), one=True)


@route("PUT", r"/api/acct/working-papers/(\d+)")
def acct_update_working_paper(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    row = db.query("SELECT client_id FROM acct_working_paper WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That working paper no longer exists")
    _assert_client_visible(u, row["client_id"])
    b = _json_body(env)
    fields = ["ref", "title", "content", "review_status", "review_comment"]
    cols = [f for f in fields if f in b]
    vals = [b[c] for c in cols]
    if "review_status" in b and b["review_status"] in ("approved", "rejected", "sent_back"):
        cols += ["reviewed_by", "reviewed_at"]; vals += [u["id"], db.now_iso()]
    if cols:
        db.execute(f"UPDATE acct_working_paper SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
                   vals + [m.group(1)])
    return db.query("SELECT * FROM acct_working_paper WHERE id = ?", (m.group(1),), one=True)


# ---- dashboard & reports

@route("GET", r"/api/acct/clients/(\d+)/dashboard")
def acct_dashboard(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    fy = (qs.get("fy_label") or [db.current_fy_label()])[0]
    provisions = db.query("SELECT * FROM acct_provision WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    prepayments = db.query("SELECT * FROM acct_prepayment WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    fx = db.query("SELECT * FROM acct_fx_item WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    interest = db.query("SELECT * FROM acct_interest_accrual WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    checklist = db.query("SELECT * FROM acct_closing_checklist_item WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    setting = db.query("SELECT * FROM acct_cash_flow_setting WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True) or {}
    cf_lines = db.query("SELECT * FROM acct_cash_flow_line WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    return {
        "fy_label": fy,
        "provisions": acct_engines.provision_movement(provisions),
        "prepayments": acct_engines.prepayment_amortization(prepayments, qs.get("as_of", [None])[0]),
        "fx": acct_engines.fx_revaluation(fx),
        "interest": acct_engines.interest_accrual(interest),
        "checklist": acct_engines.checklist_summary(checklist),
        "cash_flow": acct_engines.cash_flow_statement(cf_lines, setting.get("opening_cash", 0), setting.get("closing_cash_actual")),
    }


@route("POST", r"/api/acct/clients/(\d+)/reports")
def acct_generate_report(env, m):
    u = _user(env)
    _require_module(u, ACCT_MODULE)
    client_id = int(m.group(1))
    _assert_client_visible(u, client_id)
    b = _json_body(env)
    report_type = b.get("report_type", "adjustments_schedule")
    fmt = b.get("format", "docx")
    fy = b.get("fy_label") or db.current_fy_label()
    client = _acct_client(client_id)
    provisions = db.query("SELECT * FROM acct_provision WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    prepayments = db.query("SELECT * FROM acct_prepayment WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    fx = db.query("SELECT * FROM acct_fx_item WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    interest = db.query("SELECT * FROM acct_interest_accrual WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    equity = db.query("SELECT * FROM acct_equity_component WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    setting = db.query("SELECT * FROM acct_cash_flow_setting WHERE client_id = ? AND fy_label = ?", (client_id, fy), one=True) or {}
    cf_lines = db.query("SELECT * FROM acct_cash_flow_line WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    borrowcap = db.query("SELECT * FROM acct_borrowing_capitalization WHERE client_id = ? AND fy_label = ?", (client_id, fy))
    result = {
        "client": dict(client) if client else {}, "fy_label": fy,
        "provisions": acct_engines.provision_movement(provisions),
        "prepayments": acct_engines.prepayment_amortization(prepayments, b.get("as_of")),
        "fx": acct_engines.fx_revaluation(fx),
        "interest": acct_engines.interest_accrual(interest),
        "equity": acct_engines.equity_movement(equity),
        "cash_flow": acct_engines.cash_flow_statement(cf_lines, setting.get("opening_cash", 0), setting.get("closing_cash_actual")),
        "unverified": [],
    }
    firm = db.query("SELECT * FROM firm WHERE id = ?", (u["firm_id"],), one=True) or {}
    meta = {"firm_name": firm.get("name", ""), "firm_address": firm.get("address", ""),
            "client": client["name"] if client else "", "fy": fy, "unverified": [],
            "title": reports.TITLES.get("acct_automation", "Accounting automation")}
    try:
        path, filename = reports.build("acct_automation", f"{fmt}_{report_type}", result, meta, REPORT_DIR)
    except ValueError as e:
        raise HttpError(400, str(e))
    rid = db.execute(
        "INSERT INTO report (module_key, format, filename, storage_path, params, status, generated_by, byte_size)"
        " VALUES (?,?,?,?,?,?,?,?)",
        ("acct_automation", fmt, filename, path, json.dumps({"client_id": client_id, "fy_label": fy, "report_type": report_type}),
         "ready", u["id"], os.path.getsize(path)))
    db.audit(u["id"], "export", "report", rid, f"acct_automation.{report_type}.{fmt}")
    return {"id": rid, "filename": filename, "download_url": f"/api/reports/{rid}/download", "byte_size": os.path.getsize(path)}


# --------------------------------------------------------------- master data hub
# Phase 1 of "Integrated by Design, Independent by Operation" — see
# schema.sql's "19. MASTER DATA HUB" comment for the full rationale. Generic
# infrastructure any already-gated module can use; it carries no access rules
# of its own beyond client visibility plus a grant check on whichever
# module_key is actually doing the publishing/consuming, so it can never be
# used to route around a module's own permission boundary.

def _hub_client_id_or_404(client_id):
    row = db.query("SELECT id FROM client WHERE id = ?", (client_id,), one=True)
    if not row:
        raise HttpError(404, "That client no longer exists")
    return row["id"]


def _hub_upsert_snapshot(client_id, kind, source_module, fy_label, source_workfile_id, payload, user_id):
    # Upsert on (client, kind, fy_label, source_module): re-publishing the
    # same logical snapshot (the source was edited and recomputed) bumps
    # source_version on the SAME row rather than inserting a new one — every
    # hub_link pointing at it then sees a real version increase and reports
    # "outdated" live, instead of silently staying "synced" forever because
    # it was pointed at a now-abandoned row. Shared by the public
    # POST /api/hub/snapshots route and any module's internal write-through
    # (e.g. linked_workfiles below) — one upsert rule, everywhere.
    #
    # Version only bumps when the payload actually changed — a write-through
    # source like linked_workfiles republishes on every single page view
    # (it recomputes fresh each time by design), so treating every call as a
    # new version would flag every already-pulled row "outdated" on every
    # visit, whether or not the underlying figures moved at all.
    existing = db.query(
        "SELECT id, source_version, payload FROM hub_snapshot WHERE client_id = ? AND kind = ? AND source_module = ?"
        " AND ((fy_label IS NULL AND ? IS NULL) OR fy_label = ?)",
        (client_id, kind, source_module, fy_label, fy_label), one=True)
    payload_json = json.dumps(payload or {}, sort_keys=True)
    if existing:
        if existing["payload"] == payload_json:
            db.execute("UPDATE hub_snapshot SET computed_at = ?, computed_by = ? WHERE id = ?",
                      (db.now_iso(), user_id, existing["id"]))
            return existing["id"]
        db.execute(
            "UPDATE hub_snapshot SET source_workfile_id = ?, source_version = ?, payload = ?, computed_at = ?, computed_by = ? WHERE id = ?",
            (source_workfile_id, existing["source_version"] + 1, payload_json, db.now_iso(), user_id, existing["id"]))
        return existing["id"]
    return db.execute(
        "INSERT INTO hub_snapshot (client_id, fy_label, kind, source_module, source_workfile_id, source_version, payload, computed_by)"
        " VALUES (?,?,?,?,?,?,?,?)",
        (client_id, fy_label, kind, source_module, source_workfile_id, 1, payload_json, user_id))


@route("POST", r"/api/hub/snapshots")
def hub_publish_snapshot(env, m):
    u = _user(env)
    b = _json_body(env)
    client_id = int(b.get("client_id") or 0)
    _assert_client_visible(u, client_id)
    _hub_client_id_or_404(client_id)
    source_module = b.get("source_module")
    if not source_module:
        raise HttpError(400, "source_module is required")
    _require_module(u, source_module)
    if not b.get("kind"):
        raise HttpError(400, "kind is required")
    sid = _hub_upsert_snapshot(client_id, b["kind"], source_module, b.get("fy_label"),
                               b.get("source_workfile_id"), b.get("payload"), u["id"])
    return db.query("SELECT * FROM hub_snapshot WHERE id = ?", (sid,), one=True)


@route("GET", r"/api/hub/snapshots")
def hub_list_snapshots(env, m):
    u = _user(env)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    client_id = int((qs.get("client_id") or [0])[0])
    if not client_id:
        raise HttpError(400, "client_id is required")
    _assert_client_visible(u, client_id)
    kind = (qs.get("kind") or [None])[0]
    sql = "SELECT * FROM hub_snapshot WHERE client_id = ?"
    args = [client_id]
    if kind:
        sql += " AND kind = ?"; args.append(kind)
    rows = db.query(sql + " ORDER BY computed_at DESC", args)
    for r in rows:
        r["payload"] = json.loads(r["payload"]) if r.get("payload") else {}
    return rows


def _hub_link_effective_status(link, snapshot_version):
    if link["status"] in ("overridden", "finalized"):
        return link["status"]
    if snapshot_version > link["version_at_link"]:
        return "outdated"
    return link["status"]


@route("POST", r"/api/hub/links")
def hub_create_link(env, m):
    u = _user(env)
    b = _json_body(env)
    client_id = int(b.get("client_id") or 0)
    _assert_client_visible(u, client_id)
    consuming_module = b.get("consuming_module")
    if not consuming_module:
        raise HttpError(400, "consuming_module is required")
    _require_module(u, consuming_module)
    snap = db.query("SELECT * FROM hub_snapshot WHERE id = ? AND client_id = ?", (b.get("snapshot_id"), client_id), one=True)
    if not snap:
        raise HttpError(404, "That snapshot no longer exists")
    lid = db.execute(
        "INSERT INTO hub_link (client_id, consuming_module, consuming_ref, snapshot_id, version_at_link, linked_by)"
        " VALUES (?,?,?,?,?,?)",
        (client_id, consuming_module, b.get("consuming_ref"), snap["id"], snap["source_version"], u["id"]))
    row = db.query("SELECT * FROM hub_link WHERE id = ?", (lid,), one=True)
    row["effective_status"] = _hub_link_effective_status(row, snap["source_version"])
    return row


@route("GET", r"/api/hub/links")
def hub_list_links(env, m):
    u = _user(env)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    client_id = int((qs.get("client_id") or [0])[0])
    if not client_id:
        raise HttpError(400, "client_id is required")
    _assert_client_visible(u, client_id)
    consuming_module = (qs.get("consuming_module") or [None])[0]
    sql = "SELECT * FROM hub_link WHERE client_id = ?"
    args = [client_id]
    if consuming_module:
        sql += " AND consuming_module = ?"; args.append(consuming_module)
    rows = db.query(sql + " ORDER BY linked_at DESC", args)
    for r in rows:
        snap = db.query("SELECT source_version, kind, source_module, fy_label FROM hub_snapshot WHERE id = ?", (r["snapshot_id"],), one=True)
        r["effective_status"] = _hub_link_effective_status(r, snap["source_version"]) if snap else "requires_review"
        r["snapshot"] = snap
    return rows


@route("PUT", r"/api/hub/links/(\d+)/status")
def hub_update_link_status(env, m):
    # Only the two states that nobody but the consuming module itself can
    # know are settable here — "overridden" (the pulled figure was since
    # hand-edited) and "finalized" (the consuming workfile was signed off).
    # "Synced"/"outdated" are always derived live from the version compare,
    # never stored as a stale flag that could silently disagree with reality.
    u = _user(env)
    link = db.query("SELECT * FROM hub_link WHERE id = ?", (m.group(1),), one=True)
    if not link:
        raise HttpError(404, "That link no longer exists")
    _assert_client_visible(u, link["client_id"])
    _require_module(u, link["consuming_module"])
    b = _json_body(env)
    status = b.get("status")
    if status not in ("overridden", "finalized", "requires_review"):
        raise HttpError(400, "status must be one of: overridden, finalized, requires_review")
    db.execute("UPDATE hub_link SET status = ? WHERE id = ?", (status, m.group(1)))
    return db.query("SELECT * FROM hub_link WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/hub/links/(\d+)")
def hub_delete_link(env, m):
    u = _user(env)
    link = db.query("SELECT * FROM hub_link WHERE id = ?", (m.group(1),), one=True)
    if not link:
        raise HttpError(404, "That link no longer exists")
    _assert_client_visible(u, link["client_id"])
    _require_module(u, link["consuming_module"])
    db.execute("DELETE FROM hub_link WHERE id = ?", (m.group(1),))
    return {"ok": True}


# --------------------------------------------------------------- documents
# The schema and storage directory have existed since the first release; these
# routes were the missing piece (see README "What is not built"). Files are
# stored under CA_DATA_DIR/storage, named by id so an original filename with
# awkward characters is never touched by the filesystem.

DOC_STORAGE_DIR = os.path.join(db.DATA_DIR, "storage")
CV_STORAGE_DIR = os.path.join(db.DATA_DIR, "cv_storage")
MEDIA_DIR = os.path.join(db.DATA_DIR, "media")
CERT_DIR = os.path.join(db.DATA_DIR, "certs")
DOWNLOADS_DIR = os.path.join(db.DATA_DIR, "downloads")
_ALLOWED_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}


@route("GET", r"/api/documents")
def list_documents(env, m):
    u = _user(env)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    eng_id = (qs.get("engagement_id") or [None])[0]
    client_id = (qs.get("client_id") or [None])[0]
    sql = ("SELECT d.* FROM document d"
           " JOIN client c ON c.id = d.client_id WHERE c.firm_id = ?")
    args = [u["firm_id"]]
    if eng_id:
        sql += " AND d.engagement_id = ?"; args.append(eng_id)
    if client_id:
        sql += " AND d.client_id = ?"; args.append(client_id)
    if not u["is_super_admin"]:
        visible = db.visible_client_ids(u)
        if not visible:
            return []
        sql += f" AND c.id IN ({','.join('?' * len(visible))})"; args += list(visible)
    return db.query(sql + " ORDER BY d.uploaded_at DESC", args)


@route("POST", r"/api/documents")
def upload_document(env, m):
    u = _user(env)
    b = _json_body(env)
    if not b.get("filename") or not b.get("content_base64"):
        raise HttpError(400, "A document needs a filename and its content")
    if not b.get("engagement_id") and not b.get("client_id"):
        raise HttpError(400, "A document needs an engagement or a client")
    doc_client_id = b.get("client_id")
    if not doc_client_id and b.get("engagement_id"):
        eng = db.query("SELECT client_id FROM engagement WHERE id = ?", (b["engagement_id"],), one=True)
        doc_client_id = eng["client_id"] if eng else None
    _assert_client_visible(u, int(doc_client_id or 0))
    try:
        blob = base64.b64decode(b["content_base64"])
    except Exception:
        raise HttpError(400, "The file content was not valid base64")
    os.makedirs(DOC_STORAGE_DIR, exist_ok=True)
    sha = hashlib.sha256(blob).hexdigest()
    did = db.execute(
        "INSERT INTO document (engagement_id, client_id, category, filename, mime, byte_size,"
        " sha256, storage_path, uploaded_by) VALUES (?,?,?,?,?,?,?,?,?)",
        (b.get("engagement_id"), b.get("client_id"), b.get("category", "other"), b["filename"],
         b.get("mime", "application/octet-stream"), len(blob), sha, "", u["id"]))
    storage_path = os.path.join(DOC_STORAGE_DIR, f"{did}-{sha[:12]}")
    with open(storage_path, "wb") as f:
        f.write(blob)
    db.execute("UPDATE document SET storage_path = ? WHERE id = ?", (storage_path, did))
    db.audit(u["id"], "create", "document", did, b["filename"])
    return db.query("SELECT * FROM document WHERE id = ?", (did,), one=True)


@route("GET", r"/api/documents/(\d+)/download")
def download_document(env, m):
    u = _user(env)
    d = db.query("SELECT * FROM document WHERE id = ?", (m.group(1),), one=True)
    if not d or not d["storage_path"] or not os.path.exists(d["storage_path"]):
        raise HttpError(404, "That file is no longer on the server.")
    if d["client_id"]:
        _assert_client_visible(u, d["client_id"])
    with open(d["storage_path"], "rb") as f:
        blob = f.read()
    return ("__raw__", blob, d["mime"] or "application/octet-stream", d["filename"])


@route("PUT", r"/api/documents/(\d+)")
def update_document(env, m):
    u = _user(env)
    d = db.query("SELECT * FROM document WHERE id = ?", (m.group(1),), one=True)
    if not d:
        raise HttpError(404, "That document no longer exists")
    if d["client_id"]:
        _assert_client_visible(u, d["client_id"])
    b = _json_body(env)
    if "visible_to_client" in b:
        if not d["client_id"]:
            raise HttpError(400, "This document isn't attached to a client, so it can't be shared through the portal")
        db.execute("UPDATE document SET visible_to_client = ? WHERE id = ?",
                  (1 if b["visible_to_client"] else 0, m.group(1)))
    db.audit(u["id"], "update", "document", m.group(1), d["filename"])
    return db.query("SELECT * FROM document WHERE id = ?", (m.group(1),), one=True)


@route("DELETE", r"/api/documents/(\d+)")
def delete_document(env, m):
    u = _user(env)
    d = db.query("SELECT * FROM document WHERE id = ?", (m.group(1),), one=True)
    if not d:
        raise HttpError(404, "That document no longer exists")
    if d["client_id"]:
        _assert_client_visible(u, d["client_id"])
    if d["storage_path"] and os.path.exists(d["storage_path"]):
        os.remove(d["storage_path"])
    db.execute("DELETE FROM document WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "document", m.group(1), d["filename"])
    return {"ok": True}


# ------------------------------------------------------------- admin / users
# Provisioning is deliberately not open self-signup: a super admin creates
# the account and decides which modules it can see from day one, then the
# person sets their own password via a one-time invite link. SMTP is
# optional (see notify.py) — the link is always handed back in the response
# too, so an admin without SMTP configured can send it by hand.

def _base_url(env):
    scheme = env.get("wsgi.url_scheme", "http")
    host = env.get("HTTP_HOST") or "localhost"
    return f"{scheme}://{host}"


_DATE_RX = re.compile(r"^\d{4}-\d{2}-\d{2}$")


def _assert_grants_within_firm_plan(firm_id, module_keys):
    """A firm's own admin can only ever grant a staff member a module the
    platform admin has actually entitled that firm to — this is the write
    side of the same rule db.granted_modules()/​_require_module enforce on
    read. Raises a clear 400 rather than silently dropping the offending
    key, so an admin who tries to grant something outside the plan finds
    out why, not just that it didn't stick."""
    keys = [k for k in module_keys if k]
    if not keys:
        return
    placeholders = ",".join("?" * len(keys))
    entitled = {r["module_key"] for r in db.query(
        f"SELECT module_key FROM firm_module_grant WHERE firm_id = ? AND module_key IN ({placeholders})",
        [firm_id] + keys)}
    missing = set(keys) - entitled
    if missing:
        raise HttpError(400, "Your firm's plan doesn't include: " + ", ".join(sorted(missing)) +
                             " — ask CA-Automator to add it before granting it to staff")


def _normalize_module_grants(b):
    """{key: 'YYYY-MM-DD'|null} — module_grants if the caller sent it (this
    app's own Admin screen, which lets an admin set a validity period per
    module), else module_keys treated as permanent grants (no expiry) for
    whoever still sends the plain list."""
    if "module_grants" in b:
        out = {}
        for key, expires in (b.get("module_grants") or {}).items():
            if expires and not _DATE_RX.match(str(expires)):
                raise HttpError(400, f"'{expires}' is not a valid date (expected YYYY-MM-DD)")
            out[key] = expires or None
        return out
    if "module_keys" in b:
        return {key: None for key in (b.get("module_keys") or [])}
    return None


def _user_out(row, module_keys=None, pending_invite=False, module_grants=None):
    d = {"id": row["id"], "full_name": row["full_name"], "email": row["email"], "role": row["role"],
         "is_super_admin": bool(row["is_super_admin"]), "is_active": bool(row["is_active"]),
         "membership_no": row["membership_no"], "last_login_at": row["last_login_at"],
         "pending_invite": pending_invite}
    if module_keys is not None:
        d["module_keys"] = module_keys
    # {module_key: expires_at_or_null} — only present for a currently-valid grant;
    # a lapsed grant is dropped from module_keys too, same as db.granted_modules().
    if module_grants is not None:
        d["module_grants"] = module_grants
    return d


@route("GET", r"/api/admin/users")
def admin_list_users(env, m):
    u = _user(env)
    _require_admin(u)
    users = db.query("SELECT * FROM app_user WHERE firm_id = ? ORDER BY full_name", (u["firm_id"],))
    grants = db.query(
        "SELECT g.user_id, g.module_key, g.expires_at,"
        " (g.expires_at IS NOT NULL AND g.expires_at < date('now')) AS lapsed"
        " FROM user_module_grant g JOIN app_user au ON au.id = g.user_id"
        " WHERE au.firm_id = ?", (u["firm_id"],))
    # module_keys: currently-live grants only (what actually works right now).
    # module_grants: every grant row including lapsed ones, so the Admin
    # screen can show — and let the admin renew — an expired grant instead of
    # it just silently disappearing from the list.
    by_user_keys, by_user_grants = {}, {}
    for g in grants:
        by_user_grants.setdefault(g["user_id"], {})[g["module_key"]] = g["expires_at"]
        if not g["lapsed"]:
            by_user_keys.setdefault(g["user_id"], []).append(g["module_key"])
    pending = {r["user_id"] for r in db.query(
        "SELECT DISTINCT ui.user_id FROM user_invite ui JOIN app_user au ON au.id = ui.user_id"
        " WHERE au.firm_id = ? AND ui.used_at IS NULL AND ui.expires_at > ?", (u["firm_id"], db.now_iso()))}
    return [_user_out(r, by_user_keys.get(r["id"], []), r["id"] in pending, by_user_grants.get(r["id"], {}))
           for r in users]


@route("POST", r"/api/admin/users")
def admin_create_user(env, m):
    u = _user(env)
    _require_admin(u)
    b = _json_body(env)
    full_name = (b.get("full_name") or "").strip()
    email = (b.get("email") or "").strip().lower()
    if not full_name or not email:
        raise HttpError(400, "A name and email are required")
    if db.query("SELECT 1 FROM app_user WHERE email = ?", (email,), one=True):
        raise HttpError(400, "That email is already in use")
    firm = db.query("SELECT max_seats FROM firm WHERE id = ?", (u["firm_id"],), one=True)
    seats_used = db.query("SELECT COUNT(*) n FROM app_user WHERE firm_id = ? AND is_active = 1",
                          (u["firm_id"],), one=True)["n"]
    if firm and seats_used >= firm["max_seats"]:
        raise HttpError(400, f"Seat limit reached ({seats_used}/{firm['max_seats']} active users) —"
                             " deactivate someone first, or ask CA-Automator to increase your plan")
    grants = _normalize_module_grants(b) or {}
    _assert_grants_within_firm_plan(u["firm_id"], grants.keys())
    # An unusable random password — login fails until the invite is
    # accepted and a real password is set.
    new_id = db.create_user(u["firm_id"], full_name, email, secrets.token_urlsafe(32),
                            role=b.get("role") or "staff", membership_no=b.get("membership_no"))
    for key, expires_at in grants.items():
        db.execute("INSERT OR IGNORE INTO user_module_grant (user_id, module_key, granted_by, expires_at) VALUES (?,?,?,?)",
                   (new_id, key, u["id"], expires_at))
    token = db.create_invite(new_id)
    link = f"{_base_url(env)}/#/invite/{token}"
    if notify.smtp_configured():
        notify.send_email(email, "You've been added to CA Automator",
                          f"Hi {full_name},\n\n{u['full_name']} has set up your account.\n"
                          f"Set your password to get started:\n{link}\n\nThis link expires in 7 days.")
    db.audit(u["id"], "create", "app_user", new_id, f"{full_name} <{email}>")
    return {"user": _user_out(db.query("SELECT * FROM app_user WHERE id = ?", (new_id,), one=True),
                              list(grants.keys()), True, grants),
            "invite_link": link}


@route("PUT", r"/api/admin/users/(\d+)")
def admin_update_user(env, m):
    u = _user(env)
    _require_admin(u)
    target_id = int(m.group(1))
    target = db.query("SELECT * FROM app_user WHERE id = ? AND firm_id = ?", (target_id, u["firm_id"]), one=True)
    if not target:
        raise HttpError(404, "That user no longer exists")
    b = _json_body(env)

    demoting = target["is_super_admin"] and (
        ("is_super_admin" in b and not b["is_super_admin"]) or ("is_active" in b and not b["is_active"]))
    if demoting:
        if target_id == u["id"]:
            raise HttpError(400, "You can't remove your own super admin access — have another admin do it")
        remaining = db.query(
            "SELECT COUNT(*) n FROM app_user WHERE firm_id = ? AND is_super_admin = 1 AND is_active = 1 AND id != ?",
            (u["firm_id"], target_id), one=True)["n"]
        if remaining == 0:
            raise HttpError(400, "This is the only active super admin — promote someone else first")

    reactivating = "is_active" in b and b["is_active"] and not target["is_active"]
    if reactivating:
        firm = db.query("SELECT max_seats FROM firm WHERE id = ?", (u["firm_id"],), one=True)
        seats_used = db.query("SELECT COUNT(*) n FROM app_user WHERE firm_id = ? AND is_active = 1",
                              (u["firm_id"],), one=True)["n"]
        if firm and seats_used >= firm["max_seats"]:
            raise HttpError(400, f"Seat limit reached ({seats_used}/{firm['max_seats']} active users) —"
                                 " deactivate someone else first, or ask CA-Automator to increase your plan")

    sets, args = [], []
    if "role" in b:
        sets.append("role = ?"); args.append(b["role"])
    if "is_super_admin" in b:
        sets.append("is_super_admin = ?"); args.append(1 if b["is_super_admin"] else 0)
    if "is_active" in b:
        sets.append("is_active = ?"); args.append(1 if b["is_active"] else 0)
    if sets:
        args.append(target_id)
        db.execute(f"UPDATE app_user SET {', '.join(sets)} WHERE id = ?", args)
    if "is_active" in b and not b["is_active"]:
        db.execute("UPDATE auth_session SET revoked_at = ? WHERE user_id = ?", (db.now_iso(), target_id))

    new_grants = _normalize_module_grants(b)
    if new_grants is not None:
        _assert_grants_within_firm_plan(u["firm_id"], new_grants.keys())
        db.execute("DELETE FROM user_module_grant WHERE user_id = ?", (target_id,))
        for key, expires_at in new_grants.items():
            db.execute("INSERT OR IGNORE INTO user_module_grant (user_id, module_key, granted_by, expires_at) VALUES (?,?,?,?)",
                       (target_id, key, u["id"], expires_at))

    db.audit(u["id"], "update", "app_user", target_id,
            json.dumps({k: b[k] for k in b if k not in ("module_keys", "module_grants")}))
    grants_detail = db.module_grants_detail(target_id)
    return _user_out(db.query("SELECT * FROM app_user WHERE id = ?", (target_id,), one=True),
                     db.granted_modules(target_id), module_grants=grants_detail)


# ------------------------------------------------------ platform admin
# CA-Automator's own operator screen — every tenant firm, its seat usage,
# its granted modules, and its staff nested underneath its name (the
# "under client name, the user registered should be visible to super
# admin" requirement). Nothing here is reachable without is_platform_admin.

def _firm_out(row):
    d = dict(row)
    d["seats_used"] = db.query("SELECT COUNT(*) n FROM app_user WHERE firm_id = ? AND is_active = 1",
                               (row["id"],), one=True)["n"]
    sub = _firm_subscription_status(row["id"])
    d["subscription_status"] = sub["status"]
    d["subscription_grace_ends_at"] = sub["grace_ends_at"]
    return d


@route("GET", r"/api/platform/firms")
def platform_list_firms(env, m):
    u = _user(env)
    _require_platform_admin(u)
    firms = db.query("SELECT * FROM firm ORDER BY name")
    return [_firm_out(f) for f in firms]


@route("GET", r"/api/platform/firms/(\d+)")
def platform_get_firm(env, m):
    u = _user(env)
    _require_platform_admin(u)
    firm = db.query("SELECT * FROM firm WHERE id = ?", (m.group(1),), one=True)
    if not firm:
        raise HttpError(404, "That firm no longer exists")
    out = _firm_out(firm)
    out["staff"] = db.query("SELECT id, full_name, email, role, is_super_admin, is_active, last_login_at"
                            " FROM app_user WHERE firm_id = ? ORDER BY full_name", (m.group(1),))
    out["modules"] = [r["module_key"] for r in db.query(
        "SELECT module_key FROM firm_module_grant WHERE firm_id = ? ORDER BY module_key", (m.group(1),))]
    return out


@route("POST", r"/api/platform/firms")
def platform_create_firm(env, m):
    """Creates the tenant and its first user in one step — that user is
    immediately made this new firm's own super admin, the same bootstrap
    _ensure_super_admin gives the very first firm, so there's always
    someone able to sign in and invite the rest of their own team."""
    u = _user(env)
    _require_platform_admin(u)
    b = _json_body(env)
    name = (b.get("name") or "").strip()
    owner_name = (b.get("owner_name") or "").strip()
    owner_email = (b.get("owner_email") or "").strip().lower()
    if not name or not owner_name or not owner_email:
        raise HttpError(400, "A firm name, owner name and owner email are required")
    if db.query("SELECT 1 FROM app_user WHERE email = ?", (owner_email,), one=True):
        raise HttpError(400, "That owner email is already in use")
    max_seats = int(b.get("max_seats") or 5)
    if max_seats < 1:
        raise HttpError(400, "A firm needs at least 1 seat")
    firm_id = db.execute(
        "INSERT INTO firm (name, short_name, max_seats, plan_label, is_active, subscription_expires_at,"
        " phone, pan, registration_no, logo_path, show_on_landing) VALUES (?,?,?,?,1,?,?,?,?,?,?)",
        (name, b.get("short_name") or name, max_seats, b.get("plan_label"), b.get("subscription_expires_at") or None,
         b.get("phone"), b.get("pan"), b.get("registration_no"), b.get("logo_path"),
         1 if b.get("show_on_landing") else 0))
    for key in (b.get("module_keys") or []):
        if key in db.ALL_GRANTABLE_MODULE_KEYS:
            db.execute("INSERT OR IGNORE INTO firm_module_grant (firm_id, module_key, granted_by) VALUES (?,?,?)",
                      (firm_id, key, u["id"]))
    owner_id = db.create_user(firm_id, owner_name, owner_email, secrets.token_urlsafe(32), role="partner")
    db.execute("UPDATE app_user SET is_super_admin = 1 WHERE id = ?", (owner_id,))
    token = db.create_invite(owner_id)
    link = f"{_base_url(env)}/#/invite/{token}"
    if notify.smtp_configured():
        notify.send_email(owner_email, "Your CA-Automator account is ready",
                          f"Hi {owner_name},\n\nA CA-Automator workspace for {name} has been set up, and you're"
                          f" its administrator.\n\nSet your password to get started:\n{link}\n\n"
                          "This link expires in 7 days.")
    db.audit(u["id"], "create", "firm", firm_id, name)
    firm = db.query("SELECT * FROM firm WHERE id = ?", (firm_id,), one=True)
    return {"firm": _firm_out(firm), "owner_invite_link": link}


@route("PUT", r"/api/platform/firms/(\d+)")
def platform_update_firm(env, m):
    u = _user(env)
    _require_platform_admin(u)
    firm = db.query("SELECT * FROM firm WHERE id = ?", (m.group(1),), one=True)
    if not firm:
        raise HttpError(404, "That firm no longer exists")
    b = _json_body(env)
    sets, args = [], []
    if "name" in b:
        sets.append("name = ?"); args.append(b["name"])
    if "max_seats" in b:
        new_max = int(b["max_seats"])
        if new_max < 1:
            raise HttpError(400, "A firm needs at least 1 seat")
        seats_used = db.query("SELECT COUNT(*) n FROM app_user WHERE firm_id = ? AND is_active = 1",
                              (m.group(1),), one=True)["n"]
        if new_max < seats_used:
            raise HttpError(400, f"This firm already has {seats_used} active users — can't set the limit below that")
        sets.append("max_seats = ?"); args.append(new_max)
    if "plan_label" in b:
        sets.append("plan_label = ?"); args.append(b["plan_label"])
    if "is_active" in b:
        sets.append("is_active = ?"); args.append(1 if b["is_active"] else 0)
    if "phone" in b:
        sets.append("phone = ?"); args.append(b["phone"])
    if "pan" in b:
        sets.append("pan = ?"); args.append(b["pan"])
    if "registration_no" in b:
        sets.append("registration_no = ?"); args.append(b["registration_no"])
    if "logo_path" in b:
        sets.append("logo_path = ?"); args.append(b["logo_path"])
    if "show_on_landing" in b:
        sets.append("show_on_landing = ?"); args.append(1 if b["show_on_landing"] else 0)
    if "subscription_expires_at" in b:
        sets.append("subscription_expires_at = ?"); args.append(b["subscription_expires_at"] or None)
        # A renewed (or newly perpetual) date resets both notice flags, so
        # the next time this firm actually lapses it gets fresh grace/
        # suspension notices instead of staying silent forever because it
        # already fired once on some earlier expiry date.
        sets.append("subscription_grace_notified_at = NULL")
        sets.append("subscription_suspended_notified_at = NULL")
    if not sets:
        raise HttpError(400, "Nothing to update")
    args.append(m.group(1))
    db.execute(f"UPDATE firm SET {', '.join(sets)} WHERE id = ?", args)
    db.audit(u["id"], "update", "firm", m.group(1), json.dumps(b))
    return _firm_out(db.query("SELECT * FROM firm WHERE id = ?", (m.group(1),), one=True))


@route("PUT", r"/api/platform/firms/(\d+)/modules")
def platform_set_firm_modules(env, m):
    """Replaces this firm's entire module entitlement set. Revoking a
    module here takes effect immediately for every one of that firm's
    staff (db.granted_modules()/_require_module both check this table),
    even if they were individually granted it before — the plan is the
    ceiling, not just a gate at grant-time."""
    u = _user(env)
    _require_platform_admin(u)
    if not db.query("SELECT 1 FROM firm WHERE id = ?", (m.group(1),), one=True):
        raise HttpError(404, "That firm no longer exists")
    b = _json_body(env)
    keys = [k for k in (b.get("module_keys") or []) if k in db.ALL_GRANTABLE_MODULE_KEYS]
    db.execute("DELETE FROM firm_module_grant WHERE firm_id = ?", (m.group(1),))
    for key in keys:
        db.execute("INSERT INTO firm_module_grant (firm_id, module_key, granted_by) VALUES (?,?,?)",
                  (m.group(1), key, u["id"]))
    db.audit(u["id"], "update", "firm_module_grant", m.group(1), f"{len(keys)} modules")
    return {"ok": True, "module_keys": keys}


@route("GET", r"/api/platform/grantable-modules")
def platform_grantable_modules(env, m):
    _user(env)
    return sorted(db.ALL_GRANTABLE_MODULE_KEYS)


# --------------------------------------------------------- role templates
# Reusable module-grant presets ("HR gets Career, Accountant gets Billing").
# firm_id NULL = global built-in, manageable only by a platform admin;
# firm_id set = that firm's own custom template, manageable by that firm's
# own super admin. GET returns both (global ones first) so either kind of
# admin sees the full picture and can apply any of them when creating or
# editing a staff member.

def _role_template_out(row):
    d = dict(row)
    try:
        d["module_grants"] = json.loads(d["module_grants"]) if d.get("module_grants") else []
    except (TypeError, ValueError):
        d["module_grants"] = []
    return d


@route("GET", r"/api/admin/role-templates")
def admin_list_role_templates(env, m):
    u = _user(env)
    _require_admin(u)
    rows = db.query("SELECT * FROM role_template WHERE firm_id IS NULL OR firm_id = ?"
                    " ORDER BY firm_id IS NOT NULL, name", (u["firm_id"],))
    return [_role_template_out(r) for r in rows]


@route("POST", r"/api/admin/role-templates")
def admin_create_role_template(env, m):
    u = _user(env)
    _require_admin(u)
    b = _json_body(env)
    name = (b.get("name") or "").strip()
    if not name:
        raise HttpError(400, "A name is required")
    grants = [k for k in (b.get("module_grants") or []) if k in db.ALL_GRANTABLE_MODULE_KEYS]
    is_global = bool(b.get("is_global")) and u["is_platform_admin"]
    _assert_grants_within_firm_plan(u["firm_id"], grants) if not is_global else None
    key = b.get("key") or re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
    tid = db.execute(
        "INSERT INTO role_template (firm_id, `key`, name, description, module_grants, is_builtin, created_by)"
        " VALUES (?,?,?,?,?,?,?)",
        (None if is_global else u["firm_id"], key, name, b.get("description") or "",
         json.dumps(grants), 1 if is_global else 0, u["id"]))
    db.audit(u["id"], "create", "role_template", tid, name)
    return _role_template_out(db.query("SELECT * FROM role_template WHERE id = ?", (tid,), one=True))


@route("PUT", r"/api/admin/role-templates/(\d+)")
def admin_update_role_template(env, m):
    u = _user(env)
    _require_admin(u)
    row = db.query("SELECT * FROM role_template WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That role template no longer exists")
    if row["firm_id"] is None and not u["is_platform_admin"]:
        raise HttpError(403, "Only a CA-Automator platform admin can edit a built-in role")
    if row["firm_id"] is not None and row["firm_id"] != u["firm_id"]:
        raise HttpError(404, "That role template no longer exists")
    b = _json_body(env)
    sets, args = [], []
    if "name" in b:
        sets.append("name = ?"); args.append(b["name"])
    if "description" in b:
        sets.append("description = ?"); args.append(b["description"])
    if "module_grants" in b:
        grants = [k for k in (b["module_grants"] or []) if k in db.ALL_GRANTABLE_MODULE_KEYS]
        if row["firm_id"] is not None:
            _assert_grants_within_firm_plan(u["firm_id"], grants)
        sets.append("module_grants = ?"); args.append(json.dumps(grants))
    if not sets:
        raise HttpError(400, "Nothing to update")
    sets.append("updated_at = datetime('now')")
    args.append(m.group(1))
    db.execute(f"UPDATE role_template SET {', '.join(sets)} WHERE id = ?", args)
    db.audit(u["id"], "update", "role_template", m.group(1))
    return _role_template_out(db.query("SELECT * FROM role_template WHERE id = ?", (m.group(1),), one=True))


@route("DELETE", r"/api/admin/role-templates/(\d+)")
def admin_delete_role_template(env, m):
    u = _user(env)
    _require_admin(u)
    row = db.query("SELECT * FROM role_template WHERE id = ?", (m.group(1),), one=True)
    if not row:
        return {"ok": True}
    if row["firm_id"] is None and not u["is_platform_admin"]:
        raise HttpError(403, "Only a CA-Automator platform admin can remove a built-in role")
    if row["firm_id"] is not None and row["firm_id"] != u["firm_id"]:
        raise HttpError(404, "That role template no longer exists")
    db.execute("DELETE FROM role_template WHERE id = ?", (m.group(1),))
    db.audit(u["id"], "delete", "role_template", m.group(1), row["name"])
    return {"ok": True}


@route("POST", r"/api/admin/users/(\d+)/invite")
def admin_reissue_invite(env, m):
    # For a pending user this re-sends their original account-setup link; for
    # an already-active one it's a password reset the admin can hand over
    # directly — the same "always a way in without SMTP" fallback the login
    # screen's own forgot-password flow relies on.
    u = _user(env)
    _require_admin(u)
    target = db.query("SELECT * FROM app_user WHERE id = ? AND firm_id = ?", (m.group(1), u["firm_id"]), one=True)
    if not target:
        raise HttpError(404, "That user no longer exists")
    already_active = db.query(
        "SELECT 1 FROM user_invite WHERE user_id = ? AND used_at IS NOT NULL", (target["id"],), one=True)
    if already_active:
        token = db.create_password_reset(target["id"])
        link = f"{_base_url(env)}/#/reset/{token}"
        kind = "reset"
    else:
        token = db.create_invite(target["id"])
        link = f"{_base_url(env)}/#/invite/{token}"
        kind = "invite"
    if notify.smtp_configured():
        notify.send_email(target["email"], "Your CA Automator sign-in link",
                          f"Hi {target['full_name']},\n\nUse this link to set your password:\n{link}")
    db.audit(u["id"], kind, "app_user", target["id"], target["email"])
    return {"link": link, "kind": kind}


@route("GET", r"/api/admin/rate-remarks")
def admin_list_rate_remarks(env, m):
    # rate_master is shared platform-wide data, so remarks against it (and
    # who submitted them, across every tenant firm) are visible only to
    # CA-Automator's own platform admin — a firm's own super admin no
    # longer sees other firms' staff names/remarks here (see also
    # _require_platform_admin on update_rate/delete_rate above, which is
    # who'd actually act on a remark anyway).
    u = _user(env)
    _require_platform_admin(u)
    return db.query(
        "SELECT rr.*, r.domain, r.code, r.label rate_label, au.full_name submitted_by_name"
        " FROM rate_remark rr JOIN rate_master r ON r.id = rr.rate_id"
        " JOIN app_user au ON au.id = rr.user_id"
        " WHERE rr.status = 'open' ORDER BY rr.created_at")


@route("PUT", r"/api/admin/rate-remarks/(\d+)")
def admin_resolve_rate_remark(env, m):
    u = _user(env)
    _require_platform_admin(u)
    b = _json_body(env)
    row = db.query("SELECT * FROM rate_remark WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "That remark no longer exists")
    db.execute(
        "UPDATE rate_remark SET status = 'resolved', resolved_at = ?, resolved_by = ?, resolution_note = ?"
        " WHERE id = ?", (db.now_iso(), u["id"], b.get("resolution_note") or "", m.group(1)))
    db.execute(
        "INSERT INTO notification (user_id, kind, title, body, link) VALUES (?,?,?,?,?)",
        (row["user_id"], "rate_remark_resolved", "Your rate remark was resolved",
         b.get("resolution_note") or "", "#/rates"))
    db.audit(u["id"], "resolve", "rate_remark", m.group(1), b.get("resolution_note"))
    return {"ok": True}


@route("GET", r"/api/admin/fiscal-years")
def admin_list_fiscal_years(env, m):
    u = _user(env)
    _require_admin(u)
    return db.query("SELECT * FROM fiscal_year ORDER BY label_bs")


@route("POST", r"/api/admin/fiscal-years")
def admin_create_fiscal_year(env, m):
    # BS and AD dates are both required from the admin directly, never
    # derived from one or the other here — the app's own BS<->AD converter
    # (nepal_calendar.bs_approx) is explicitly an approximation, and a fiscal
    # year's official boundaries should come from the real calendar.
    u = _user(env)
    _require_admin(u)
    b = _json_body(env)
    label_bs = (b.get("label_bs") or "").strip()
    fields = {k: (b.get(k) or "").strip() for k in ("start_bs", "end_bs", "start_ad", "end_ad")}
    if not label_bs or not all(fields.values()):
        raise HttpError(400, "Label, both BS dates and both AD dates are all required")
    try:
        row = db.create_fiscal_year(label_bs, fields["start_bs"], fields["end_bs"], fields["start_ad"], fields["end_ad"])
    except db.IntegrityError:
        raise HttpError(400, "A fiscal year with this label already exists")
    db.audit(u["id"], "create", "fiscal_year", row["id"], label_bs)
    return row


# ----------------------------------------------------- financial periods
# Country-neutral counterpart to fiscal-years above, for any firm whose
# country_code isn't NP — see schema.sql's comment on financial_period and
# server/fiscal_year_engine.py for why this is a separate table/screen
# rather than an extension of the Nepal-native one.

@route("GET", r"/api/admin/financial-periods")
def admin_list_financial_periods(env, m):
    u = _user(env)
    _require_admin(u)
    qs = parse_qs(env.get("QUERY_STRING", ""))
    country_code = (qs.get("country_code") or [None])[0] or country_service.firm_country_code(u["firm_id"])
    fiscal_year_engine.ensure_periods_exist(country_code)
    return fiscal_year_engine.periods_for_country(country_code)


@route("POST", r"/api/admin/financial-periods")
def admin_create_financial_period(env, m):
    u = _user(env)
    _require_admin(u)
    b = _json_body(env)
    country_code = (b.get("country_code") or "").strip()
    label = (b.get("label") or "").strip()
    start_ad = (b.get("start_ad") or "").strip()
    end_ad = (b.get("end_ad") or "").strip()
    if not (country_code and label and start_ad and end_ad):
        raise HttpError(400, "country_code, label, start_ad and end_ad are all required")
    try:
        pid = db.execute(
            "INSERT INTO financial_period (country_code, label, start_ad, end_ad) VALUES (?,?,?,?)",
            (country_code, label, start_ad, end_ad))
    except db.IntegrityError:
        raise HttpError(400, "This country already has a financial period with that label")
    fiscal_year_engine.sync_current_period(country_code)
    db.audit(u["id"], "create", "financial_period", pid, f"{country_code} {label}")
    return db.query("SELECT * FROM financial_period WHERE id = ?", (pid,), one=True)


@route("POST", r"/api/admin/financial-periods/generate")
def admin_generate_financial_periods(env, m):
    """Auto-generates a spread of years for a country with a known
    convention (currently IN, AE — see fiscal_year_engine.GENERATION_RULES).
    A country without a rule 400s with a clear message rather than silently
    guessing one."""
    u = _user(env)
    _require_admin(u)
    b = _json_body(env)
    country_code = (b.get("country_code") or "").strip()
    if not country_code:
        raise HttpError(400, "country_code is required")
    try:
        fiscal_year_engine.seed_default_periods(
            country_code, around_year=b.get("around_year"),
            back=int(b.get("back", 2)), forward=int(b.get("forward", 3)))
    except ValueError as e:
        raise HttpError(400, str(e))
    db.audit(u["id"], "generate", "financial_period", None, country_code)
    return fiscal_year_engine.periods_for_country(country_code)


@route("DELETE", r"/api/admin/financial-periods/(\d+)")
def admin_delete_financial_period(env, m):
    u = _user(env)
    _require_admin(u)
    row = db.query("SELECT country_code FROM financial_period WHERE id = ?", (m.group(1),), one=True)
    if not row:
        raise HttpError(404, "Not found")
    db.execute("DELETE FROM financial_period WHERE id = ?", (m.group(1),))
    fiscal_year_engine.sync_current_period(row["country_code"])
    db.audit(u["id"], "delete", "financial_period", int(m.group(1)))
    return {"ok": True}


# --------------------------------------------------------- invite / reset
# Public routes — the token itself is the credential, no session required.

@route("POST", r"/api/invite/accept")
def accept_invite(env, m):
    b = _json_body(env)
    password = b.get("password") or ""
    if len(password) < 8:
        raise HttpError(400, "Use at least 8 characters")
    user = db.consume_invite(b.get("token") or "", password)
    if not user:
        raise HttpError(400, "This invite link is invalid or has expired — ask your admin to resend it")
    token = db.issue_session(user["id"], b.get("device_id"))
    db.audit(user["id"], "invite_accepted", "app_user", user["id"])
    return {"token": token, "user": {"id": user["id"], "name": user["full_name"], "email": user["email"],
                                     "role": user["role"], "is_super_admin": bool(user["is_super_admin"])}}


@route("POST", r"/api/auth/forgot")
def forgot_password(env, m):
    b = _json_body(env)
    email = (b.get("email") or "").strip().lower()
    user = db.query("SELECT * FROM app_user WHERE email = ? AND is_active = 1", (email,), one=True)
    if user:
        token = db.create_password_reset(user["id"])
        link = f"{_base_url(env)}/#/reset/{token}"
        if notify.smtp_configured():
            notify.send_email(email, "Reset your CA Automator password",
                              f"Use this link to set a new password:\n{link}\n\nThis link expires in 24 hours.")
        db.audit(user["id"], "reset_requested", "app_user", user["id"])
    # Same response whether or not the email matched — don't confirm which
    # accounts exist to whoever is asking.
    return {"ok": True, "message": "If that email has an account, a reset link has been generated for it."}


@route("POST", r"/api/auth/reset")
def reset_password(env, m):
    b = _json_body(env)
    password = b.get("password") or ""
    if len(password) < 8:
        raise HttpError(400, "Use at least 8 characters")
    user = db.consume_password_reset(b.get("token") or "", password)
    if not user:
        raise HttpError(400, "This reset link is invalid or has expired — request a new one")
    db.execute("UPDATE auth_session SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL",
              (db.now_iso(), user["id"]))
    token = db.issue_session(user["id"], b.get("device_id"))
    db.audit(user["id"], "reset_completed", "app_user", user["id"])
    return {"token": token, "user": {"id": user["id"], "name": user["full_name"], "email": user["email"],
                                     "role": user["role"], "is_super_admin": bool(user["is_super_admin"])}}


def _portal_user_out(pu):
    return {"id": pu["id"], "name": pu["name"], "email": pu["email"], "client_id": pu["client_id"],
           "client_name": pu["client_name"]}


@route("POST", r"/api/portal/auth/login")
def portal_login(env, m):
    body = _json_body(env)
    email = (body.get("email") or "").lower().strip()
    pu = db.query("SELECT * FROM client_portal_user WHERE email = ? AND is_active = 1", (email,), one=True)
    if not pu or not db.verify_password(body.get("password") or "", pu["pwd_hash"], pu["pwd_salt"]):
        raise HttpError(401, "That email and password don't match a portal account")
    token = db.portal_issue_session(pu["id"])
    db.execute("UPDATE client_portal_user SET last_login_at = ? WHERE id = ?", (db.now_iso(), pu["id"]))
    client = db.query("SELECT name FROM client WHERE id = ?", (pu["client_id"],), one=True)
    return {"token": token, "user": _portal_user_out(dict(pu, client_name=client["name"]))}


@route("GET", r"/api/portal/me")
def portal_me(env, m):
    """Resolves whatever session token the caller has — issued at a
    password login or an OAuth one, this route can't tell the difference
    and doesn't need to. Used by the OAuth return trip, which only gets a
    bare token back from the redirect and needs the user's name/client to
    show, same shape POST /api/portal/auth/login already returns."""
    pu = _portal_user(env)
    return _portal_user_out(pu)


@route("GET", r"/api/portal/oauth/providers")
def portal_oauth_providers(env, m):
    """Public — the portal login screen calls this to decide which social
    buttons to show. Empty list = show none (the "blank until configured"
    posture every integration in this app follows)."""
    return oauth_login.configured_providers(1)


@route("GET", r"/api/portal/oauth/([a-z0-9_-]+)/start")
def portal_oauth_start(env, m):
    """A plain browser navigation (the login button is a real <a href>, not
    a fetch call) — OAuth requires leaving this app entirely for the
    provider's own sign-in page, so this has to be a redirect, not JSON."""
    try:
        url = oauth_login.start(m.group(1), 1, _base_url(env))
    except oauth_login.OAuthError:
        return ("__redirect__", "/portal.html?oauth_error=not_configured#/login")
    return ("__redirect__", url)


_OAUTH_ERROR_MESSAGES = {
    "invalid_state": "invalid_state", "state_expired": "invalid_state", "not_configured": "not_configured",
    "no_portal_account": "no_portal_account", "no_verified_email": "no_verified_email",
}


@route("GET", r"/api/portal/oauth/([a-z0-9_-]+)/callback")
def portal_oauth_callback(env, m):
    qs = parse_qs(env.get("QUERY_STRING", ""))
    code = (qs.get("code") or [None])[0]
    state = (qs.get("state") or [None])[0]
    if qs.get("error"):
        # The visitor cancelled at Google's own consent screen, or Google
        # itself refused — either way there's no code to exchange.
        return ("__redirect__", "/portal.html?oauth_error=cancelled#/login")
    if not code or not state:
        return ("__redirect__", "/portal.html?oauth_error=invalid_state#/login")
    try:
        pu = oauth_login.complete(m.group(1), code, state, _base_url(env))
    except oauth_login.OAuthError as e:
        error_code = _OAUTH_ERROR_MESSAGES.get(str(e), "failed")
        return ("__redirect__", f"/portal.html?oauth_error={error_code}#/login")
    token = db.portal_issue_session(pu["id"])
    db.execute("UPDATE client_portal_user SET last_login_at = ? WHERE id = ?", (db.now_iso(), pu["id"]))
    return ("__redirect__", f"/portal.html?oauth_token={token}#/")


@route("POST", r"/api/portal/auth/logout")
def portal_logout(env, m):
    _portal_user(env)
    auth = env.get("HTTP_AUTHORIZATION", "")
    db.execute("UPDATE client_portal_session SET revoked_at = ? WHERE token = ?",
              (db.now_iso(), auth[7:].strip()))
    return {"ok": True}


@route("POST", r"/api/portal/invite/accept")
def portal_accept_invite(env, m):
    b = _json_body(env)
    password = b.get("password") or ""
    if len(password) < 8:
        raise HttpError(400, "Use at least 8 characters")
    pu = db.portal_consume_invite(b.get("token") or "", password)
    if not pu:
        raise HttpError(400, "This invite link is invalid or has expired — ask the firm to resend it")
    token = db.portal_issue_session(pu["id"])
    client = db.query("SELECT name FROM client WHERE id = ?", (pu["client_id"],), one=True)
    db.audit(None, "invite_accepted", "client_portal_user", pu["id"])
    return {"token": token, "user": _portal_user_out(dict(pu, client_name=client["name"]))}


@route("POST", r"/api/portal/auth/forgot")
def portal_forgot_password(env, m):
    b = _json_body(env)
    email = (b.get("email") or "").strip().lower()
    pu = db.query("SELECT * FROM client_portal_user WHERE email = ? AND is_active = 1", (email,), one=True)
    if pu:
        token = db.portal_create_password_reset(pu["id"])
        link = f"{_base_url(env)}/portal.html#/reset/{token}"
        notify.send_email(email, "Reset your client portal password",
                          f"Use this link to set a new password:\n{link}\n\nThis link expires in 24 hours.",
                          related_type="client_portal_user", related_id=pu["id"])
    return {"ok": True, "message": "If that email has portal access, a reset link has been generated for it."}


@route("POST", r"/api/portal/auth/reset")
def portal_reset_password(env, m):
    b = _json_body(env)
    password = b.get("password") or ""
    if len(password) < 8:
        raise HttpError(400, "Use at least 8 characters")
    pu = db.portal_consume_password_reset(b.get("token") or "", password)
    if not pu:
        raise HttpError(400, "This reset link is invalid or has expired — request a new one")
    db.execute("UPDATE client_portal_session SET revoked_at = ? WHERE portal_user_id = ? AND revoked_at IS NULL",
              (db.now_iso(), pu["id"]))
    token = db.portal_issue_session(pu["id"])
    client = db.query("SELECT name FROM client WHERE id = ?", (pu["client_id"],), one=True)
    return {"token": token, "user": _portal_user_out(dict(pu, client_name=client["name"]))}


@route("GET", r"/api/portal/bootstrap")
def portal_bootstrap(env, m):
    pu = _portal_user(env)
    firm = db.query("SELECT * FROM firm WHERE id = ?", (pu["firm_id"],), one=True) or {}
    client = db.query("SELECT * FROM client WHERE id = ?", (pu["client_id"],), one=True)
    return {
        "user": _portal_user_out(pu),
        "client": {"name": client["name"], "code": client["code"], "pan": client["pan"],
                  "address": client["address"], "entity_type": client["entity_type"]},
        "firm": {k: firm.get(k) for k in _FIRM_PUBLIC_FIELDS},
    }


@route("GET", r"/api/portal/appointments")
def portal_appointments(env, m):
    pu = _portal_user(env)
    return db.query(
        "SELECT b.id, b.name, b.method, b.service, b.status, b.price, s.date, s.start_time, s.end_time"
        " FROM appointment_booking b JOIN appointment_slot s ON s.id = b.slot_id"
        " WHERE b.client_id = ? ORDER BY s.date DESC, s.start_time DESC", (pu["client_id"],))


@route("GET", r"/api/portal/invoices")
def portal_invoices(env, m):
    pu = _portal_user(env)
    return db.query(
        "SELECT id, invoice_no, invoice_date, due_date, status, subtotal, vat_amount, total, paid_amount, paid_date"
        " FROM invoice WHERE client_id = ? ORDER BY invoice_date DESC", (pu["client_id"],))


@route("GET", r"/api/portal/documents")
def portal_documents(env, m):
    pu = _portal_user(env)
    return db.query(
        "SELECT id, filename, category, byte_size, uploaded_at FROM document"
        " WHERE client_id = ? AND visible_to_client = 1 ORDER BY uploaded_at DESC", (pu["client_id"],))


@route("GET", r"/api/portal/documents/(\d+)/download")
def portal_download_document(env, m):
    pu = _portal_user(env)
    d = db.query("SELECT * FROM document WHERE id = ? AND client_id = ? AND visible_to_client = 1",
                (m.group(1), pu["client_id"]), one=True)
    if not d or not d["storage_path"] or not os.path.exists(d["storage_path"]):
        raise HttpError(404, "That file is no longer available")
    with open(d["storage_path"], "rb") as f:
        blob = f.read()
    return ("__raw__", blob, d["mime"] or "application/octet-stream", d["filename"])


@route("POST", r"/api/portal/service-requests")
def portal_service_request(env, m):
    pu = _portal_user(env)
    b = _json_body(env)
    message = (b.get("message") or "").strip()
    if not message:
        raise HttpError(400, "Describe what you need")
    service_id = b.get("service_id") or None
    if service_id and not db.query("SELECT 1 FROM service WHERE id = ?", (service_id,), one=True):
        service_id = None
    lid = db.execute(
        "INSERT INTO `lead` (name, email, phone, message, subject, service_id, client_id, utm_source, status, firm_id)"
        " VALUES (?,?,?,?,?,?,?, 'client_portal', 'qualified', ?)",
        (pu["name"], pu["email"], None, message, b.get("subject") or "Service request", service_id, pu["client_id"],
         pu["firm_id"]))
    # Only THIS client's own firm gets notified — a service request naming a
    # real client used to be broadcast to every super admin on the entire
    # platform (no firm_id filter here at all), leaking that client's name
    # and request straight to unrelated tenant firms.
    for staff in db.query("SELECT id FROM app_user WHERE firm_id = ? AND is_super_admin = 1 AND is_active = 1",
                          (pu["firm_id"],)):
        db.execute("INSERT INTO notification (user_id, kind, title, body, link) VALUES (?,?,?,?,?)",
                   (staff["id"], "portal_request", f"Service request from {pu['client_name']}", message[:200], "/#/leads"))
    return {"ok": True, "id": lid}


@route("GET", r"/api/health")
def health(env, m):
    return {"ok": True, "time": db.now_iso(), "unverified_rates": db.unverified_count()}


# --------------------------------------------------------------- static files

def _serve_static(path):
    rel = "index.html" if path in ("/", "") else path.lstrip("/")
    full = os.path.normpath(os.path.join(WEB_DIR, rel))
    if not full.startswith(WEB_DIR) or not os.path.isfile(full):
        return None
    ctype = mimetypes.guess_type(full)[0] or "application/octet-stream"
    if full.endswith(".webmanifest"):
        ctype = "application/manifest+json"
    with open(full, "rb") as f:
        return f.read(), ctype


# --------------------------------------------------------------- WSGI entry

def application(env, start_response):
    method = env["REQUEST_METHOD"]
    path = env.get("PATH_INFO", "/")

    if method == "OPTIONS":
        start_response("204 No Content", [
            ("Access-Control-Allow-Origin", "*"),
            ("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Device-Id"),
            ("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")])
        return [b""]

    if not path.startswith("/api/"):
        # A small, fixed set of public paths need to be dynamic (server-
        # rendered from the database on every request) rather than the
        # static app shell — search crawlers need real title/meta/canonical/
        # schema/content in the FIRST response for blog posts, job postings
        # and services, and a sitemap/robots.txt can't be static since these
        # change without a deploy. Every other non-/api/ path (including the
        # authenticated app itself) keeps using the SPA shell + client-side
        # fetch, unaffected — the landing page's own #services grid still
        # works exactly as before, this only adds the dedicated pages.
        qs = parse_qs(env.get("QUERY_STRING", ""))
        dyn = None
        if path == "/sitemap.xml":
            dyn = ("application/xml; charset=utf-8", public_pages.sitemap_xml(env))
        elif path == "/robots.txt":
            dyn = ("text/plain; charset=utf-8", public_pages.robots_txt(env))
        elif path == "/blog":
            dyn = ("text/html; charset=utf-8", public_pages.blog_list_page(env, qs))
        elif path == "/careers":
            dyn = ("text/html; charset=utf-8", public_pages.careers_list_page(env))
        elif path == "/services":
            dyn = ("text/html; charset=utf-8", public_pages.services_list_page(env))
        elif path.startswith("/blog/"):
            page = public_pages.blog_detail_page(env, path[len("/blog/"):])
            if page is not None:
                dyn = ("text/html; charset=utf-8", page)
        elif path.startswith("/careers/"):
            page = public_pages.careers_detail_page(env, path[len("/careers/"):])
            if page is not None:
                dyn = ("text/html; charset=utf-8", page)
        elif path.startswith("/services/"):
            page = public_pages.services_detail_page(env, path[len("/services/"):])
            if page is not None:
                dyn = ("text/html; charset=utf-8", page)
        elif path.startswith("/team/"):
            page = public_pages.team_detail_page(env, path[len("/team/"):])
            if page is not None:
                dyn = ("text/html; charset=utf-8", page)
        elif path.startswith("/page/"):
            page = public_pages.static_page(env, path[len("/page/"):])
            if page is not None:
                dyn = ("text/html; charset=utf-8", page)
        elif path == "/downloads":
            dyn = ("text/html; charset=utf-8", public_pages.downloads_list_page(env))
        elif path.startswith("/downloads/file/") or path.startswith("/downloads/view/"):
            # The actual file bytes — separate from the SEO-friendly
            # /downloads/<slug> detail page above. /file/ forces a download
            # (Content-Disposition: attachment), /view/ opens inline in the
            # browser (e.g. a PDF viewer tab) — same file, same public, no
            # auth (this whole module exists to be publicly findable).
            inline = path.startswith("/downloads/view/")
            prefix_len = len("/downloads/view/") if inline else len("/downloads/file/")
            try:
                did = int(path[prefix_len:])
            except ValueError:
                did = None
            row = db.query("SELECT * FROM download_file WHERE id = ? AND is_published = 1 AND firm_id = ?",
                          (did, _default_firm_id()), one=True) if did else None
            if row:
                full = os.path.join(DOWNLOADS_DIR, row["file_path"])
                if os.path.isfile(full):
                    db.execute("UPDATE download_file SET download_count = download_count + 1 WHERE id = ?", (did,))
                    with open(full, "rb") as f:
                        body = f.read()
                    disposition = "inline" if inline else "attachment"
                    fname = (row["original_filename"] or row["file_path"]).replace('"', "'")
                    fname = "".join(c for c in fname if c.isprintable())
                    start_response("200 OK", [
                        ("Content-Type", row["mime_type"] or "application/octet-stream"),
                        ("Content-Length", str(len(body))),
                        ("Content-Disposition", f'{disposition}; filename="{fname}"')])
                    return [body]
        elif path.startswith("/downloads/"):
            page = public_pages.downloads_detail_page(env, path[len("/downloads/"):])
            if page is not None:
                dyn = ("text/html; charset=utf-8", page)
        elif path.startswith("/media/"):
            # Uploaded images (blog cover art today) — deliberately stored
            # under CA_DATA_DIR, not web/, so runtime content never mixes
            # with the application's own source tree; this is the one place
            # that reads it back out publicly, no auth (a cover image has to
            # be visible to a browser that isn't logged in). basename()
            # strips any path components the filename might contain, so
            # this can never be tricked into reading outside MEDIA_DIR.
            fname = os.path.basename(path[len("/media/"):])
            full = os.path.join(MEDIA_DIR, fname)
            if fname and os.path.isfile(full):
                with open(full, "rb") as f:
                    dyn = (mimetypes.guess_type(full)[0] or "application/octet-stream", f.read())
        if dyn is not None:
            ctype, body = dyn
            start_response("200 OK", [("Content-Type", ctype), ("Content-Length", str(len(body)))])
            return [body]

        served = _serve_static(path)
        if served is None and not path.startswith("/static"):
            served = _serve_static("/")           # SPA fallback
        if served is None:
            start_response("404 Not Found", [("Content-Type", "text/plain")])
            return [b"Not found"]
        body, ctype = served
        headers = [("Content-Type", ctype), ("Content-Length", str(len(body)))]
        if path.endswith("sw.js"):
            headers.append(("Service-Worker-Allowed", "/"))
            headers.append(("Cache-Control", "no-cache"))
        start_response("200 OK", headers)
        return [body]

    for m_method, rx, fn in ROUTES:
        if m_method != method:
            continue
        match = rx.match(path)
        if not match:
            continue
        try:
            result = fn(env, match)
        except HttpError as e:
            body = json.dumps({"error": e.message}).encode()
            start_response(f"{e.status} Error", [("Content-Type", "application/json"),
                                                 ("Content-Length", str(len(body)))])
            return [body]
        except Exception:
            traceback.print_exc()
            body = json.dumps({"error": "Something failed on the server. Your work is still saved "
                                        "locally in this browser."}).encode()
            start_response("500 Internal Server Error",
                           [("Content-Type", "application/json"), ("Content-Length", str(len(body)))])
            return [body]

        if isinstance(result, tuple) and result and result[0] == "__raw__":
            _, blob, ctype, filename = result
            start_response("200 OK", [
                ("Content-Type", ctype),
                ("Content-Disposition", f'attachment; filename="{filename}"'),
                ("Content-Length", str(len(blob)))])
            return [blob]

        if isinstance(result, tuple) and result and result[0] == "__redirect__":
            # A gateway callback (Khalti/ConnectIPS) lands here as a plain
            # browser navigation, not a fetch() call — a JSON body would
            # just be dumped on screen, so this sends the visitor on to a
            # real page (the portal or the site) instead.
            _, location = result
            start_response("302 Found", [("Location", location), ("Content-Length", "0")])
            return [b""]

        body = json.dumps(result, ensure_ascii=False, default=str).encode()
        start_response("200 OK", [("Content-Type", "application/json"),
                                  ("Content-Length", str(len(body))),
                                  ("Access-Control-Allow-Origin", "*")])
        return [body]

    body = json.dumps({"error": f"No route for {method} {path}"}).encode()
    start_response("404 Not Found", [("Content-Type", "application/json"),
                                     ("Content-Length", str(len(body)))])
    return [body]


db.init_db()
