"""
Canteen Asset Tracker — Admin Server.

Standalone FastAPI server for admin-only operations.
Uses the same SQLite database as the main canteen-asset-tracker server
(via CANTEEN_DB_PATH env var), sharing assets.db for all CRUD operations
on users, customers, locations, rooms, geofences, settings, activity log,
stats, CSV exports, and database management.

Designed to run on a separate port from the main field-worker server.
"""
import csv
import hashlib
import httpx
import io
import json as _json
import os
import secrets
import sqlite3
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from cantaloupe_sync import router as cantaloupe_router, create_sync_tables
from exif_module import router as exif_router, create_exif_tables

# ─── Config ─────────────────────────────────────────────────────────────────

DB_PATH = os.environ.get("CANTEEN_DB_PATH", str(Path(__file__).parent / "assets.db"))
UPLOADS_DIR = Path(os.environ.get("CANTEEN_UPLOADS_DIR", str(Path(__file__).parent / ".." / "canteen-asset-tracker" / "uploads")))
ICON_MAX_SIZE = 2 * 1024 * 1024       # 2 MB
ICON_ALLOWED_EXTS = {".png", ".jpg", ".jpeg", ".svg"}
PHOTO_ALLOWED_EXTS = {".png", ".jpg", ".jpeg"}

def _load_categories() -> set:
    """Load valid category names from the categories lookup table."""
    try:
        conn = sqlite3.connect(DB_PATH)
        names = {r[0] for r in conn.execute("SELECT name FROM categories").fetchall()}
        conn.close()
        return names if names else {"Other"}
    except Exception:
        return {"Furniture", "Appliances", "Utensils & Serveware", "Equipment", "Other"}

VALID_CATEGORIES = _load_categories()
VALID_STATUSES = {"active", "maintenance", "retired"}
VALID_ROLES = {"admin", "technician", "readonly"}
# Helpers for comma-separated multi-role support
def roles_contain(role_str: str, target: str) -> bool:
    """Check if a comma-separated role string includes target."""
    return target in [r.strip() for r in (role_str or "").split(",")]

def validate_roles(role_str: str) -> str:
    """Accept comma-separated role combos. Normalise: strip whitespace, dedupe, sort."""
    parts = [r.strip().lower() for r in role_str.split(",")]
    parts = [r for r in parts if r]  # drop empties
    if not parts:
        raise HTTPException(status_code=422, detail="At least one role is required")
    invalid = [r for r in parts if r not in VALID_ROLES]
    if invalid:
        raise HTTPException(
            status_code=422,
            detail=f"Invalid role(s): {', '.join(invalid)}. Must be one of: {', '.join(sorted(VALID_ROLES))}",
        )
    return ",".join(sorted(set(parts)))


# ─── Database ───────────────────────────────────────────────────────────────


def get_db() -> sqlite3.Connection:
    """Return a new DB connection with WAL + foreign keys enabled."""
    conn = sqlite3.connect(DB_PATH)
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA foreign_keys=ON")
    conn.row_factory = sqlite3.Row
    return conn


def _create_tables(conn: sqlite3.Connection):
    """Create all tables if they don't exist (same schema as main server)."""
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS users (
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
            username        TEXT    UNIQUE NOT NULL,
            password_hash   TEXT    NOT NULL,
            role            TEXT    NOT NULL DEFAULT 'technician',
            created_at      TEXT    NOT NULL DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS customers (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            name        TEXT    UNIQUE NOT NULL,
            created_at  TEXT    NOT NULL DEFAULT (datetime('now')),
            updated_at  TEXT    NOT NULL DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS customer_contacts (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
            name        TEXT,
            phone       TEXT,
            email       TEXT
        );

        CREATE TABLE IF NOT EXISTS locations (
            id                  INTEGER PRIMARY KEY AUTOINCREMENT,
            customer_id         INTEGER REFERENCES customers(id),
            name                TEXT,
            address             TEXT    DEFAULT '',
            building_name       TEXT    DEFAULT '',
            building_number     TEXT    DEFAULT '',
            floor               TEXT    DEFAULT '',
            trailer_number      TEXT    DEFAULT '',
            site_hours          TEXT    DEFAULT '',
            access_notes        TEXT    DEFAULT '',
            walking_directions  TEXT    DEFAULT '',
            map_link            TEXT    DEFAULT '',
            latitude            REAL    DEFAULT NULL,
            longitude           REAL    DEFAULT NULL,
            created_at          TEXT    NOT NULL DEFAULT (datetime('now')),
            updated_at          TEXT    NOT NULL DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS rooms (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            location_id INTEGER NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
            name        TEXT,
            floor       TEXT    DEFAULT '',
            created_at  TEXT    NOT NULL DEFAULT (datetime('now')),
            updated_at  TEXT    NOT NULL DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS categories (
            id      INTEGER PRIMARY KEY AUTOINCREMENT,
            name    TEXT    UNIQUE NOT NULL,
            icon    TEXT    DEFAULT ''
        );

        CREATE TABLE IF NOT EXISTS key_names (
            id      INTEGER PRIMARY KEY AUTOINCREMENT,
            name    TEXT    UNIQUE NOT NULL
        );

        CREATE TABLE IF NOT EXISTS key_types (
            id      INTEGER PRIMARY KEY AUTOINCREMENT,
            name    TEXT    UNIQUE NOT NULL
        );

        CREATE TABLE IF NOT EXISTS badge_types (
            id      INTEGER PRIMARY KEY AUTOINCREMENT,
            name    TEXT    UNIQUE NOT NULL
        );

        CREATE TABLE IF NOT EXISTS makes (
            id      INTEGER PRIMARY KEY AUTOINCREMENT,
            name    TEXT    UNIQUE NOT NULL
        );

        CREATE TABLE IF NOT EXISTS models (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            make_id     INTEGER NOT NULL REFERENCES makes(id),
            name        TEXT    NOT NULL,
            icon_path   TEXT
        );

        CREATE TABLE IF NOT EXISTS assets (
            id                  INTEGER PRIMARY KEY AUTOINCREMENT,
            machine_id          TEXT    NOT NULL UNIQUE,
            serial_number       TEXT    DEFAULT '',
            name                TEXT    NOT NULL,
            description         TEXT    DEFAULT '',
            category            TEXT    NOT NULL DEFAULT 'Other',
            status              TEXT    NOT NULL DEFAULT 'active',
            make                TEXT    DEFAULT '',
            model               TEXT    DEFAULT '',
            address             TEXT    DEFAULT '',
            building_name       TEXT    DEFAULT '',
            building_number     TEXT    DEFAULT '',
            floor               TEXT    DEFAULT '',
            room                TEXT    DEFAULT '',
            trailer_number      TEXT    DEFAULT '',
            walking_directions  TEXT    DEFAULT '',
            map_link            TEXT    DEFAULT '',
            parking_location    TEXT    DEFAULT '',
            photo_path          TEXT,
            customer_id         INTEGER REFERENCES customers(id),
            location_id         INTEGER REFERENCES locations(id),
            assigned_to         INTEGER REFERENCES users(id),
            latitude            REAL    DEFAULT NULL,
            longitude           REAL    DEFAULT NULL,
            geofence_radius_meters INTEGER DEFAULT 50,
            created_at          TEXT    NOT NULL DEFAULT (datetime('now')),
            updated_at          TEXT    NOT NULL DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS checkins (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            asset_id    INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
            user_id     INTEGER REFERENCES users(id),
            latitude    REAL,
            longitude   REAL,
            accuracy    REAL,
            photo_path  TEXT,
            notes       TEXT    DEFAULT '',
            created_at  TEXT    NOT NULL DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS asset_keys (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            asset_id    INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
            key_name    TEXT,
            key_type    TEXT
        );

        CREATE TABLE IF NOT EXISTS asset_badges (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            asset_id    INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
            badge_name  TEXT
        );

        CREATE TABLE IF NOT EXISTS settings (
            id      INTEGER PRIMARY KEY AUTOINCREMENT,
            key     TEXT    UNIQUE NOT NULL,
            value   TEXT
        );

        CREATE TABLE IF NOT EXISTS activity_log (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id     INTEGER REFERENCES users(id),
            action      TEXT,
            entity_type TEXT,
            entity_id   INTEGER,
            details     TEXT,
            created_at  TEXT    NOT NULL DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS geofences (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            name        TEXT,
            points      TEXT,
            color       TEXT,
            created_at  TEXT    NOT NULL DEFAULT (datetime('now')),
            updated_at  TEXT    NOT NULL DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS geofence_users (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            geofence_id INTEGER NOT NULL REFERENCES geofences(id) ON DELETE CASCADE,
            user_id     INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            UNIQUE(geofence_id, user_id)
        );

        CREATE TABLE IF NOT EXISTS visits (
            id                  INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id             INTEGER REFERENCES users(id),
            asset_id            INTEGER REFERENCES assets(id) ON DELETE CASCADE,
            checkin_time        TEXT,
            checkout_time       TEXT,
            duration_minutes    INTEGER,
            created_at          TEXT    NOT NULL DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS sessions (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id     INTEGER REFERENCES users(id) ON DELETE CASCADE,
            token       TEXT    UNIQUE NOT NULL,
            created_at  TEXT    NOT NULL DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS cantaloupe_sync_batches (
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
            status          TEXT    NOT NULL DEFAULT 'pending',
            created_at      TEXT    NOT NULL DEFAULT (datetime('now')),
            approved_at     TEXT,
            file_path       TEXT,
            row_count       INTEGER DEFAULT 0,
            diff_summary    TEXT,
            raw_data        TEXT,
            error_message   TEXT
        );

        CREATE INDEX IF NOT EXISTS idx_checkins_asset_id ON checkins(asset_id);
        CREATE INDEX IF NOT EXISTS idx_checkins_created_at ON checkins(created_at);
        CREATE INDEX IF NOT EXISTS idx_assets_category ON assets(category);
        CREATE INDEX IF NOT EXISTS idx_assets_machine_id ON assets(machine_id);
        CREATE INDEX IF NOT EXISTS idx_csb_status ON cantaloupe_sync_batches(status);
        CREATE INDEX IF NOT EXISTS idx_csb_created ON cantaloupe_sync_batches(created_at);
    """)


def _seed_if_empty(conn: sqlite3.Connection, table: str, columns: tuple, rows: list):
    """Insert seed rows if table is empty."""
    existing = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
    if existing == 0:
        placeholders = ", ".join(["?"] * len(columns))
        col_names = ", ".join(columns)
        conn.executemany(
            f"INSERT INTO {table} ({col_names}) VALUES ({placeholders})",
            rows,
        )


def _seed_data(conn: sqlite3.Connection):
    """Insert default seed data for lookup tables."""
    _seed_if_empty(conn, "categories", ("name", "icon"), [
        ("Furniture", "🪑"), ("Appliances", "🔌"),
        ("Utensils & Serveware", "🍽️"), ("Equipment", "⚙️"), ("Other", "📦"),
    ])
    _seed_if_empty(conn, "key_names", ("name",), [
        ("MK500",), ("Green Dot",), ("Red Key",), ("Blue Key",),
        ("Master Key",), ("Padlock Key",),
    ])
    _seed_if_empty(conn, "key_types", ("name",), [
        ("Round Short",), ("Barrel",), ("Standard",), ("Flat",), ("Tubular",),
    ])
    _seed_if_empty(conn, "badge_types", ("name",), [
        ("Disney Contractor Base",), ("Visitor Badge",), ("Employee Badge",),
        ("Contractor Badge",), ("Temporary Pass",),
    ])
    _seed_if_empty(conn, "makes", ("name",), [
        ("Canteen",), ("Hobart",), ("Vollrath",), ("Metro",),
        ("Rubbermaid",), ("Cambro",), ("Other",),
    ])
    # Ensure default admin user exists
    conn.execute(
        "INSERT OR IGNORE INTO users (username, password_hash, role) VALUES (?, ?, ?)",
        ("admin", "057ba03d6c44104863dc7361fe4578965d1887360f90a0895882e58a6248fc86", "admin"),
    )


def init_db(conn: sqlite3.Connection):
    """Ensure all tables exist, run migrations if needed, seed default data."""
    _create_tables(conn)
    create_sync_tables(conn)
    _seed_data(conn)
    # Add lat/lng columns if missing (v3 migration)
    cursor = conn.execute("PRAGMA table_info(assets)")
    asset_cols = {row[1] for row in cursor.fetchall()}
    if "latitude" not in asset_cols:
        conn.execute("ALTER TABLE assets ADD COLUMN latitude REAL DEFAULT NULL")
    if "longitude" not in asset_cols:
        conn.execute("ALTER TABLE assets ADD COLUMN longitude REAL DEFAULT NULL")
    if "geofence_radius_meters" not in asset_cols:
        conn.execute("ALTER TABLE assets ADD COLUMN geofence_radius_meters INTEGER DEFAULT 50")
    # v4 migration: dex_report_date, deployed, pulled_date
    if "dex_report_date" not in asset_cols:
        conn.execute("ALTER TABLE assets ADD COLUMN dex_report_date TEXT DEFAULT NULL")
    if "deployed" not in asset_cols:
        conn.execute("ALTER TABLE assets ADD COLUMN deployed TEXT DEFAULT NULL")
    if "pulled_date" not in asset_cols:
        conn.execute("ALTER TABLE assets ADD COLUMN pulled_date TEXT DEFAULT NULL")
    # v5 migration: install_date
    if "install_date" not in asset_cols:
        conn.execute("ALTER TABLE assets ADD COLUMN install_date TEXT DEFAULT NULL")
    cursor = conn.execute("PRAGMA table_info(locations)")
    loc_cols = {row[1] for row in cursor.fetchall()}
    if "latitude" not in loc_cols:
        conn.execute("ALTER TABLE locations ADD COLUMN latitude REAL DEFAULT NULL")
    if "longitude" not in loc_cols:
        conn.execute("ALTER TABLE locations ADD COLUMN longitude REAL DEFAULT NULL")
    conn.commit()


# ─── App / Middleware ───────────────────────────────────────────────────────


@asynccontextmanager
async def lifespan(app: FastAPI):
    UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
    conn = get_db()
    init_db(conn)
    conn.close()
    create_exif_tables()
    yield


app = FastAPI(title="Canteen Asset Tracker — Admin API", lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Mount Cantaloupe sync routes
app.include_router(cantaloupe_router)
app.include_router(exif_router)

# ─── Auth Middleware ────────────────────────────────────────────────────────


@app.middleware("http")
async def auth_middleware(request: Request, call_next):
    """Require valid Bearer token for all /api/* routes except login."""
    path = request.url.path

    if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
        return await call_next(request)

    if not path.startswith("/api/") or path == "/api/auth/login" or path.startswith("/api/lookup-business") or path.startswith("/api/admin/reports/"):
        return await call_next(request)

    auth_header = request.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        return JSONResponse(
            status_code=401,
            content={"detail": "Authentication required"},
        )

    token = auth_header[7:]
    conn = get_db()
    row = conn.execute(
        "SELECT u.id, u.username, u.role FROM users u "
        "JOIN sessions s ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > datetime('now')",
        (token,),
    ).fetchone()
    conn.close()

    if row is None:
        return JSONResponse(
            status_code=401,
            content={"detail": "Invalid or expired token"},
        )

    request.state.current_user = {
        "id": row["id"],
        "username": row["username"],
        "role": row["role"],
    }
    request.state.user_id = row["id"]

    return await call_next(request)


# ─── Global Error Handling ──────────────────────────────────────────────────


@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    return JSONResponse(
        status_code=exc.status_code,
        content={"detail": exc.detail},
    )


@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
    import traceback
    traceback.print_exc()
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error"},
    )


# ─── Helper functions ──────────────────────────────────────────────────────


def _hash_password(password: str) -> str:
    return hashlib.sha256(password.encode()).hexdigest()


def _generate_token() -> str:
    return secrets.token_hex(32)


def row_to_dict(row: sqlite3.Row) -> dict:
    return dict(row)


def _user_to_dict(row: sqlite3.Row) -> dict:
    d = row_to_dict(row)
    d.pop("password_hash", None)
    return d


def _log_activity(conn: sqlite3.Connection, action: str, entity_type: str,
                  entity_id: int, details: str = "", user_id: int = None):
    conn.execute(
        "INSERT INTO activity_log (user_id, action, entity_type, entity_id, details) "
        "VALUES (?, ?, ?, ?, ?)",
        (user_id, action, entity_type, entity_id, details),
    )


def _validate_ref(conn: sqlite3.Connection, table: str, id_val: int, label: str):
    if id_val is not None:
        row = conn.execute(f"SELECT id FROM {table} WHERE id = ?", (id_val,)).fetchone()
        if row is None:
            raise HTTPException(status_code=422, detail=f"{label} with id {id_val} not found")


def _validate_enum_table(conn: sqlite3.Connection, table: str, value: str, label: str):
    if value:
        row = conn.execute(
            f"SELECT id FROM {table} WHERE name = ?", (value,)
        ).fetchone()
        if row is None:
            raise HTTPException(
                status_code=422,
                detail=f"Invalid {label} '{value}'. Must exist in {table} table.",
            )


def _geofence_row(row: sqlite3.Row, conn: Optional[sqlite3.Connection] = None) -> dict:
    """Serialize a geofence row, parsing points JSON and including assigned users."""
    d = dict(row)
    if isinstance(d.get("points"), str):
        try:
            d["points"] = _json.loads(d["points"])
        except (_json.JSONDecodeError, TypeError):
            pass
    close_conn = False
    if conn is None:
        conn = get_db()
        close_conn = True
    try:
        user_rows = conn.execute(
            """SELECT u.id, u.username, u.role FROM geofence_users gu
               JOIN users u ON u.id = gu.user_id
               WHERE gu.geofence_id = ? ORDER BY u.username""",
            (d["id"],),
        ).fetchall()
        d["assigned_users"] = [dict(r) for r in user_rows]
    finally:
        if close_conn:
            conn.close()
    return d


def _sync_geofence_users(conn: sqlite3.Connection, geofence_id: int, user_ids: list[int]):
    """Replace assigned users for a geofence with the given list of user IDs."""
    if user_ids:
        placeholders = ", ".join(["?"] * len(user_ids))
        existing = conn.execute(
            f"SELECT id FROM users WHERE id IN ({placeholders})",
            user_ids,
        ).fetchall()
        existing_ids = {r["id"] for r in existing}
        for uid in user_ids:
            if uid not in existing_ids:
                raise HTTPException(
                    status_code=422,
                    detail=f"User with id {uid} not found",
                )

    conn.execute("DELETE FROM geofence_users WHERE geofence_id = ?", (geofence_id,))
    for uid in user_ids:
        conn.execute(
            "INSERT INTO geofence_users (geofence_id, user_id) VALUES (?, ?)",
            (geofence_id, uid),
        )


def _get_customer_contacts(conn: sqlite3.Connection, cust_id: int) -> list:
    rows = conn.execute(
        "SELECT id, customer_id, name, phone, email FROM customer_contacts WHERE customer_id = ?",
        (cust_id,),
    ).fetchall()
    return [row_to_dict(r) for r in rows]


def _get_location_rooms(conn: sqlite3.Connection, loc_id: int) -> list:
    rows = conn.execute(
        "SELECT id, location_id, name, floor, created_at, updated_at FROM rooms WHERE location_id = ? ORDER BY name",
        (loc_id,),
    ).fetchall()
    return [row_to_dict(r) for r in rows]


def _save_upload(upload: UploadFile, subdir: str, allowed_exts: set, max_size: int) -> str:
    """Save uploaded file to uploads/{subdir}/ with a UUID filename."""
    ext = Path(upload.filename or "").suffix.lower()
    if not ext or ext not in allowed_exts:
        allowed = ", ".join(sorted(allowed_exts))
        raise HTTPException(400, f"Invalid file type. Allowed: {allowed}")

    contents = upload.file.read()
    if len(contents) > max_size:
        mb = max_size // (1024 * 1024)
        raise HTTPException(413, f"File too large. Maximum size: {mb} MB")

    dest_dir = UPLOADS_DIR / subdir
    dest_dir.mkdir(parents=True, exist_ok=True)
    fname = f"{uuid.uuid4().hex}{ext}"
    (dest_dir / fname).write_bytes(contents)

    return f"/uploads/{subdir}/{fname}"


# ─── Pydantic Models ────────────────────────────────────────────────────────


class CustomerContact(BaseModel):
    name: Optional[str] = ""
    phone: Optional[str] = ""
    email: Optional[str] = ""


class CustomerCreate(BaseModel):
    name: str
    contacts: Optional[list[CustomerContact]] = []


class CustomerUpdate(BaseModel):
    name: Optional[str] = None
    contacts: Optional[list[CustomerContact]] = None


class LocationCreate(BaseModel):
    customer_id: Optional[int] = None
    name: str
    address: Optional[str] = ""
    building_name: Optional[str] = ""
    building_number: Optional[str] = ""
    floor: Optional[str] = ""
    trailer_number: Optional[str] = ""
    site_hours: Optional[str] = ""
    access_notes: Optional[str] = ""
    walking_directions: Optional[str] = ""
    map_link: Optional[str] = ""
    latitude: Optional[float] = None
    longitude: Optional[float] = None


class LocationUpdate(BaseModel):
    customer_id: Optional[int] = None
    name: Optional[str] = None
    address: Optional[str] = None
    building_name: Optional[str] = None
    building_number: Optional[str] = None
    floor: Optional[str] = None
    trailer_number: Optional[str] = None
    site_hours: Optional[str] = None
    access_notes: Optional[str] = None
    walking_directions: Optional[str] = None
    map_link: Optional[str] = None
    latitude: Optional[float] = None
    longitude: Optional[float] = None


class RoomCreate(BaseModel):
    location_id: int
    name: str
    floor: Optional[str] = ""


class RoomUpdate(BaseModel):
    name: Optional[str] = None
    floor: Optional[str] = None
    location_id: Optional[int] = None


class UserCreate(BaseModel):
    username: str
    password: str
    role: Optional[str] = "technician"


class UserUpdate(BaseModel):
    role: Optional[str] = None
    password: Optional[str] = None


class GeofenceCreate(BaseModel):
    name: str
    points: list
    color: Optional[str] = "#3388ff"
    user_ids: Optional[list[int]] = None


class GeofenceUpdate(BaseModel):
    name: Optional[str] = None
    points: Optional[list] = None
    color: Optional[str] = None
    user_ids: Optional[list[int]] = None


# ─── Health ─────────────────────────────────────────────────────────────────


@app.get("/health")
def health():
    return {"status": "ok"}


# ─── Users API ──────────────────────────────────────────────────────────────


@app.post("/api/users", status_code=201)
def create_user(body: UserCreate):
    username = body.username.strip()
    if not username:
        raise HTTPException(status_code=422, detail="Username must not be empty")
    password = body.password
    if not password:
        raise HTTPException(status_code=422, detail="Password must not be empty")
    role = validate_roles(body.role or "technician")

    conn = get_db()
    password_hash = _hash_password(password)
    try:
        cursor = conn.execute(
            "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
            (username, password_hash, role),
        )
        conn.commit()
        uid = cursor.lastrowid
        _log_activity(conn, "created", "user", uid, f"User '{username}' created")
        conn.commit()
    except sqlite3.IntegrityError:
        conn.close()
        raise HTTPException(status_code=409, detail=f"User '{username}' already exists")

    row = conn.execute("SELECT * FROM users WHERE id = ?", (uid,)).fetchone()
    conn.close()
    return _user_to_dict(row)


@app.get("/api/users")
def list_users():
    conn = get_db()
    rows = conn.execute("SELECT * FROM users ORDER BY username").fetchall()
    conn.close()
    return [_user_to_dict(r) for r in rows]


@app.get("/api/users/{user_id}")
def get_user(user_id: int):
    conn = get_db()
    row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
    if row is None:
        conn.close()
        raise HTTPException(status_code=404, detail="User not found")
    conn.close()
    return _user_to_dict(row)


@app.put("/api/users/{user_id}")
def update_user(user_id: int, body: UserUpdate):
    conn = get_db()
    existing = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="User not found")

    if body.role is not None:
        role = validate_roles(body.role)
        conn.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
        _log_activity(conn, "updated", "user", user_id,
                      f"User '{existing['username']}' role changed to '{role}'")
        conn.commit()

    if body.password is not None:
        pw = body.password.strip()
        if not pw:
            conn.close()
            raise HTTPException(status_code=422, detail="Password must not be empty")
        password_hash = _hash_password(pw)
        conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id))
        _log_activity(conn, "updated", "user", user_id,
                      f"User '{existing['username']}' password changed")
        conn.commit()

    conn.commit()
    row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
    conn.close()
    return _user_to_dict(row)


@app.delete("/api/users/{user_id}", status_code=204)
def delete_user(user_id: int):
    conn = get_db()
    existing = conn.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="User not found")
    conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
    conn.commit()
    _log_activity(conn, "deleted", "user", user_id,
                  f"User '{existing['username']}' deleted")
    conn.commit()
    conn.close()


@app.get("/api/users/{user_id}/geofences")
def list_user_geofences(user_id: int):
    """List geofences assigned to a user (their service areas)."""
    conn = get_db()
    existing = conn.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="User not found")
    rows = conn.execute(
        """SELECT g.* FROM geofences g
           JOIN geofence_users gu ON gu.geofence_id = g.id
           WHERE gu.user_id = ? ORDER BY g.name""",
        (user_id,),
    ).fetchall()
    result = [_geofence_row(r, conn) for r in rows]
    conn.close()
    return result


# ─── Login (needed for token-based access) ──────────────────────────────────


@app.post("/api/auth/login")
def login(body: dict):
    """Authenticate a user and return a session token."""
    username = body.get("username", "")
    password = body.get("password", "")
    conn = get_db()
    row = conn.execute(
        "SELECT * FROM users WHERE username = ?", (username,)
    ).fetchone()
    if row is None:
        conn.close()
        raise HTTPException(status_code=401, detail="Invalid username or password")

    password_hash = _hash_password(password)
    if password_hash != row["password_hash"]:
        conn.close()
        raise HTTPException(status_code=401, detail="Invalid username or password")

    token = _generate_token()
    remember_me = body.get("remember_me", False)
    expiry = "+30 days" if remember_me else "+1 day"
    conn.execute(
        "INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, datetime('now', ?))",
        (row["id"], token, expiry),
    )
    conn.commit()
    _log_activity(conn, "login", "user", row["id"],
                  f"User '{username}' logged in")
    conn.commit()
    conn.close()

    result = _user_to_dict(row)
    result["token"] = token
    return result


# ─── Auth: Me (validate token) ───────────────────────────────────────────────


@app.get("/api/auth/me")
def auth_me(request: Request):
    """Return the current authenticated user from the Authorization header."""
    auth_header = request.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
    token = auth_header[7:]
    conn = get_db()
    row = conn.execute(
        "SELECT u.* FROM users u JOIN sessions s ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > datetime('now')",
        (token,),
    ).fetchone()
    conn.close()
    if row is None:
        raise HTTPException(status_code=401, detail="Invalid or expired token")
    return _user_to_dict(row)


# ─── Customers API ──────────────────────────────────────────────────────────


@app.post("/api/customers", status_code=201)
def create_customer(body: CustomerCreate):
    name = body.name.strip()
    if not name:
        raise HTTPException(status_code=422, detail="Customer name must not be empty")
    conn = get_db()
    try:
        cursor = conn.execute("INSERT INTO customers (name) VALUES (?)", (name,))
        cust_id = cursor.lastrowid
        if body.contacts:
            for c in body.contacts:
                conn.execute(
                    "INSERT INTO customer_contacts (customer_id, name, phone, email) VALUES (?, ?, ?, ?)",
                    (cust_id, c.name or "", c.phone or "", c.email or ""),
                )
        conn.commit()
        _log_activity(conn, "created", "customer", cust_id,
                      f"Customer '{name}' created")
        conn.commit()
    except sqlite3.IntegrityError:
        conn.close()
        raise HTTPException(status_code=409, detail=f"Customer '{name}' already exists")

    row = conn.execute("SELECT * FROM customers WHERE id = ?", (cust_id,)).fetchone()
    result = row_to_dict(row)
    result["contacts"] = _get_customer_contacts(conn, cust_id)
    conn.close()
    return result


@app.get("/api/customers")
def list_customers():
    conn = get_db()
    rows = conn.execute("SELECT * FROM customers ORDER BY name").fetchall()
    result = []
    for r in rows:
        d = row_to_dict(r)
        d["contacts"] = _get_customer_contacts(conn, r["id"])
        result.append(d)
    conn.close()
    return result


@app.get("/api/customers/{cust_id}")
def get_customer(cust_id: int):
    conn = get_db()
    row = conn.execute("SELECT * FROM customers WHERE id = ?", (cust_id,)).fetchone()
    if row is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Customer not found")
    result = row_to_dict(row)
    result["contacts"] = _get_customer_contacts(conn, cust_id)
    conn.close()
    return result


@app.put("/api/customers/{cust_id}")
def update_customer(cust_id: int, body: CustomerUpdate):
    conn = get_db()
    existing = conn.execute("SELECT id FROM customers WHERE id = ?", (cust_id,)).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Customer not found")

    if body.name is not None:
        name = body.name.strip()
        if not name:
            conn.close()
            raise HTTPException(status_code=422, detail="Customer name must not be empty")
        conn.execute(
            "UPDATE customers SET name = ?, updated_at = datetime('now') WHERE id = ?",
            (name, cust_id),
        )

    if body.contacts is not None:
        conn.execute("DELETE FROM customer_contacts WHERE customer_id = ?", (cust_id,))
        for c in body.contacts:
            conn.execute(
                "INSERT INTO customer_contacts (customer_id, name, phone, email) VALUES (?, ?, ?, ?)",
                (cust_id, c.name or "", c.phone or "", c.email or ""),
            )

    conn.commit()
    row = conn.execute("SELECT * FROM customers WHERE id = ?", (cust_id,)).fetchone()
    result = row_to_dict(row)
    result["contacts"] = _get_customer_contacts(conn, cust_id)
    _log_activity(conn, "updated", "customer", cust_id,
                  f"Customer '{result['name']}' updated")
    conn.commit()
    conn.close()
    return result


@app.delete("/api/customers/{cust_id}", status_code=204)
def delete_customer(cust_id: int):
    conn = get_db()
    existing = conn.execute("SELECT id FROM customers WHERE id = ?", (cust_id,)).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Customer not found")
    conn.execute("DELETE FROM customers WHERE id = ?", (cust_id,))
    _log_activity(conn, "deleted", "customer", cust_id,
                  f"Customer {cust_id} deleted")
    conn.commit()
    conn.close()


# ─── Locations API ──────────────────────────────────────────────────────────


@app.post("/api/locations", status_code=201)
def create_location(body: LocationCreate):
    name = body.name.strip()
    if not name:
        raise HTTPException(status_code=422, detail="Location name must not be empty")
    conn = get_db()
    if body.customer_id is not None:
        _validate_ref(conn, "customers", body.customer_id, "Customer")

    cursor = conn.execute(
        """INSERT INTO locations (customer_id, name, address, building_name, building_number,
           floor, trailer_number, site_hours, access_notes, walking_directions, map_link,
           latitude, longitude)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
        (body.customer_id, name, body.address or "", body.building_name or "",
         body.building_number or "", body.floor or "", body.trailer_number or "",
         body.site_hours or "", body.access_notes or "",
         body.walking_directions or "", body.map_link or "",
         body.latitude, body.longitude),
    )
    conn.commit()
    loc_id = cursor.lastrowid
    _log_activity(conn, "created", "location", loc_id,
                  f"Location '{name}' created")
    conn.commit()
    row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone()
    result = row_to_dict(row)
    result["rooms"] = []
    conn.close()
    return result


@app.get("/api/locations")
def list_locations(customer_id: Optional[int] = Query(None)):
    conn = get_db()
    if customer_id is not None:
        rows = conn.execute(
            "SELECT * FROM locations WHERE customer_id = ? ORDER BY name",
            (customer_id,),
        ).fetchall()
    else:
        rows = conn.execute("SELECT * FROM locations ORDER BY name").fetchall()
    result = []
    for r in rows:
        d = row_to_dict(r)
        d["rooms"] = _get_location_rooms(conn, r["id"])
        result.append(d)
    conn.close()
    return result


@app.get("/api/locations/{loc_id}")
def get_location(loc_id: int):
    conn = get_db()
    row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone()
    if row is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Location not found")
    result = row_to_dict(row)
    result["rooms"] = _get_location_rooms(conn, loc_id)
    conn.close()
    return result


_LOCATION_FIELDS = [
    "customer_id", "name", "address", "building_name", "building_number",
    "floor", "trailer_number", "site_hours", "access_notes",
    "walking_directions", "map_link", "latitude", "longitude",
]


@app.put("/api/locations/{loc_id}")
def update_location(loc_id: int, body: LocationUpdate):
    conn = get_db()
    existing = conn.execute("SELECT id, name FROM locations WHERE id = ?", (loc_id,)).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Location not found")

    updates = {}
    for field in _LOCATION_FIELDS:
        val = getattr(body, field, None)
        if val is not None:
            if field == "name":
                name = val.strip()
                if not name:
                    conn.close()
                    raise HTTPException(status_code=422, detail="Location name must not be empty")
                updates[field] = name
            elif field == "customer_id":
                if val is not None:
                    _validate_ref(conn, "customers", val, "Customer")
                updates[field] = val
            elif field in ("latitude", "longitude"):
                updates[field] = val
            else:
                updates[field] = val or ""

    if updates:
        updates["updated_at"] = "datetime('now')"
        set_clause = ", ".join(
            f"{k} = {v}" if k == "updated_at" else f"{k} = ?"
            for k, v in updates.items()
        )
        values = [v for k, v in updates.items() if k != "updated_at"]
        conn.execute(
            f"UPDATE locations SET {set_clause} WHERE id = ?",
            values + [loc_id],
        )
        conn.commit()
        _log_activity(conn, "updated", "location", loc_id,
                      f"Location '{existing['name']}' updated")
        conn.commit()

    row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone()
    result = row_to_dict(row)
    result["rooms"] = _get_location_rooms(conn, loc_id)
    conn.close()
    return result


@app.delete("/api/locations/{loc_id}", status_code=204)
def delete_location(loc_id: int):
    conn = get_db()
    existing = conn.execute("SELECT id, name FROM locations WHERE id = ?", (loc_id,)).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Location not found")
    conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
    conn.commit()
    _log_activity(conn, "deleted", "location", loc_id,
                  f"Location '{existing['name']}' deleted")
    conn.commit()
    conn.close()


# ─── Rooms API ──────────────────────────────────────────────────────────────


@app.get("/api/rooms")
def list_rooms(location_id: Optional[int] = Query(None)):
    conn = get_db()
    if location_id is not None:
        rows = conn.execute(
            "SELECT * FROM rooms WHERE location_id = ? ORDER BY name",
            (location_id,),
        ).fetchall()
    else:
        rows = conn.execute("SELECT * FROM rooms ORDER BY name").fetchall()
    conn.close()
    return [row_to_dict(r) for r in rows]


@app.get("/api/rooms/{room_id}")
def get_room(room_id: int):
    conn = get_db()
    row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
    conn.close()
    if row is None:
        raise HTTPException(status_code=404, detail="Room not found")
    return row_to_dict(row)


@app.post("/api/rooms", status_code=201)
def create_room(body: RoomCreate):
    conn = get_db()
    _validate_ref(conn, "locations", body.location_id, "Location")

    cursor = conn.execute(
        "INSERT INTO rooms (location_id, name, floor) VALUES (?, ?, ?)",
        (body.location_id, body.name.strip(), body.floor or ""),
    )
    conn.commit()
    room_id = cursor.lastrowid
    _log_activity(conn, "created", "room", room_id,
                  f"Room '{body.name.strip()}' created in location {body.location_id}")
    conn.commit()
    row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
    conn.close()
    return row_to_dict(row)


@app.put("/api/rooms/{room_id}")
def update_room(room_id: int, body: RoomUpdate):
    conn = get_db()
    existing = conn.execute("SELECT id, name FROM rooms WHERE id = ?", (room_id,)).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Room not found")

    updates = {}
    if body.name is not None:
        updates["name"] = body.name.strip()
    if body.floor is not None:
        updates["floor"] = body.floor
    if body.location_id is not None:
        _validate_ref(conn, "locations", body.location_id, "Location")
        updates["location_id"] = body.location_id

    if updates:
        updates["updated_at"] = "datetime('now')"
        set_clause = ", ".join(
            f"{k} = {v}" if k == "updated_at" else f"{k} = ?"
            for k, v in updates.items()
        )
        values = [v for k, v in updates.items() if k != "updated_at"]
        conn.execute(
            f"UPDATE rooms SET {set_clause} WHERE id = ?",
            values + [room_id],
        )
        conn.commit()
        _log_activity(conn, "updated", "room", room_id,
                      f"Room '{existing['name']}' updated")
        conn.commit()

    row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
    conn.close()
    return row_to_dict(row)


@app.delete("/api/rooms/{room_id}", status_code=204)
def delete_room(room_id: int):
    conn = get_db()
    existing = conn.execute("SELECT id, name FROM rooms WHERE id = ?", (room_id,)).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Room not found")
    conn.execute("DELETE FROM rooms WHERE id = ?", (room_id,))
    conn.commit()
    _log_activity(conn, "deleted", "room", room_id,
                  f"Room '{existing['name']}' deleted")
    conn.commit()
    conn.close()


# ─── Settings API ───────────────────────────────────────────────────────────


_SETTINGS_SCHEMAS = {
    "categories":  {"table": "categories", "columns": ["name", "icon"]},
    "makes":       {"table": "makes", "columns": ["name"]},
    "models":      {"table": "models", "columns": ["make_id", "name", "icon_path"]},
    "key_names":   {"table": "key_names", "columns": ["name"]},
    "key_types":   {"table": "key_types", "columns": ["name"]},
    "badge_types": {"table": "badge_types", "columns": ["name"]},
}


def _settings_list(conn: sqlite3.Connection, table: str):
    if table == "models":
        rows = conn.execute("SELECT * FROM models ORDER BY name").fetchall()
    else:
        rows = conn.execute(f"SELECT * FROM {table} ORDER BY name").fetchall()
    return [row_to_dict(r) for r in rows]


def _settings_create(conn: sqlite3.Connection, entity: str, body: dict):
    schema = _SETTINGS_SCHEMAS[entity]
    table = schema["table"]
    columns = schema["columns"]
    values = []
    for col in columns:
        val = body.get(col, "" if col != "make_id" else None)
        if col == "make_id" and val is None:
            raise HTTPException(status_code=422, detail="make_id is required for models")
        values.append(val if val is not None else "")
    placeholders = ", ".join(["?"] * len(columns))
    col_names = ", ".join(columns)
    try:
        cursor = conn.execute(
            f"INSERT INTO {table} ({col_names}) VALUES ({placeholders})", values
        )
        conn.commit()
        eid = cursor.lastrowid
        _log_activity(conn, "created", entity, eid,
                      f"{entity[:-1].title()} '{body.get('name', '')}' created")
        conn.commit()
        row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone()
        return row_to_dict(row)
    except sqlite3.IntegrityError:
        raise HTTPException(status_code=409, detail=f"Entry already exists in {entity}")


def _settings_update(conn: sqlite3.Connection, entity: str, eid: int, body: dict):
    schema = _SETTINGS_SCHEMAS[entity]
    table = schema["table"]
    columns = schema["columns"]

    existing = conn.execute(f"SELECT id, name FROM {table} WHERE id = ?", (eid,)).fetchone()
    if existing is None:
        raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")

    updates = {}
    for col in columns:
        if col in body:
            updates[col] = body[col]

    if updates:
        set_clause = ", ".join(f"{k} = ?" for k in updates)
        values = list(updates.values())
        conn.execute(
            f"UPDATE {table} SET {set_clause} WHERE id = ?", values + [eid]
        )
        conn.commit()
        _log_activity(conn, "updated", entity, eid,
                      f"{entity[:-1].title()} '{existing['name']}' updated")
        conn.commit()

    row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone()
    return row_to_dict(row)


def _settings_get(conn: sqlite3.Connection, entity: str, eid: int):
    schema = _SETTINGS_SCHEMAS[entity]
    table = schema["table"]
    row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone()
    if row is None:
        raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
    return row_to_dict(row)


def _settings_delete(conn: sqlite3.Connection, entity: str, eid: int):
    schema = _SETTINGS_SCHEMAS[entity]
    table = schema["table"]
    existing = conn.execute(f"SELECT id, name FROM {table} WHERE id = ?", (eid,)).fetchone()
    if existing is None:
        raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
    conn.execute(f"DELETE FROM {table} WHERE id = ?", (eid,))
    conn.commit()
    _log_activity(conn, "deleted", entity, eid,
                  f"{entity[:-1].title()} '{existing['name']}' deleted")
    conn.commit()


_SETTINGS_ENTITIES = ["categories", "makes", "models", "key_names", "key_types", "badge_types"]

for _ent in _SETTINGS_ENTITIES:

    @app.get(f"/api/settings/{_ent}", name=f"list_{_ent}")
    def _list(entity=_ent):
        conn = get_db()
        result = _settings_list(conn, entity)
        conn.close()
        return result

    @app.get(f"/api/settings/{_ent}/{{eid}}", name=f"get_{_ent}")
    def _get(eid: int, entity=_ent):
        conn = get_db()
        try:
            result = _settings_get(conn, entity, eid)
            conn.close()
            return result
        except HTTPException:
            conn.close()
            raise

    @app.post(f"/api/settings/{_ent}", status_code=201, name=f"create_{_ent}")
    async def _create(request: Request, entity=_ent):
        body = await request.json()
        conn = get_db()
        try:
            result = _settings_create(conn, entity, body)
            conn.close()
            return result
        except HTTPException:
            conn.close()
            raise

    @app.put(f"/api/settings/{_ent}/{{eid}}", name=f"update_{_ent}")
    async def _update(eid: int, request: Request, entity=_ent):
        body = await request.json()
        conn = get_db()
        try:
            result = _settings_update(conn, entity, eid, body)
            conn.close()
            return result
        except HTTPException:
            conn.close()
            raise

    @app.delete(f"/api/settings/{_ent}/{{eid}}", status_code=204, name=f"delete_{_ent}")
    def _delete(eid: int, entity=_ent):
        conn = get_db()
        try:
            _settings_delete(conn, entity, eid)
            conn.close()
        except HTTPException:
            conn.close()
            raise


# ─── Geofences API ──────────────────────────────────────────────────────────


@app.post("/api/geofences", status_code=201)
def create_geofence(body: GeofenceCreate):
    conn = get_db()
    points_json = _json.dumps(body.points)
    cursor = conn.execute(
        "INSERT INTO geofences (name, points, color) VALUES (?, ?, ?)",
        (body.name, points_json, body.color or "#3388ff"),
    )
    gid = cursor.lastrowid

    if body.user_ids:
        try:
            _sync_geofence_users(conn, gid, body.user_ids)
        except Exception:
            conn.close()
            raise HTTPException(status_code=422, detail="One or more user IDs are invalid")

    _log_activity(conn, "created", "geofence", gid, f"Geofence '{body.name}' created")
    conn.commit()

    row = conn.execute("SELECT * FROM geofences WHERE id = ?", (gid,)).fetchone()
    result = _geofence_row(row, conn)
    conn.close()
    return result


@app.get("/api/geofences")
def list_geofences():
    conn = get_db()
    rows = conn.execute("SELECT * FROM geofences ORDER BY name").fetchall()
    result = [_geofence_row(r, conn) for r in rows]
    conn.close()
    return result


@app.put("/api/geofences/{geofence_id}")
def update_geofence(geofence_id: int, body: GeofenceUpdate):
    conn = get_db()
    existing = conn.execute(
        "SELECT id, name FROM geofences WHERE id = ?", (geofence_id,)
    ).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Geofence not found")

    updates = {}
    if body.name is not None:
        updates["name"] = body.name
    if body.points is not None:
        updates["points"] = _json.dumps(body.points)
    if body.color is not None:
        updates["color"] = body.color

    if updates:
        updates["updated_at"] = "datetime('now')"
        set_clause = ", ".join(
            f"{k} = {v}" if k == "updated_at" else f"{k} = ?"
            for k, v in updates.items()
        )
        values = [v for k, v in updates.items() if k != "updated_at"]
        conn.execute(
            f"UPDATE geofences SET {set_clause} WHERE id = ?",
            values + [geofence_id],
        )
        conn.commit()

    if body.user_ids is not None:
        _sync_geofence_users(conn, geofence_id, body.user_ids)
        conn.commit()

    _log_activity(conn, "updated", "geofence", geofence_id,
                  f"Geofence '{existing['name']}' updated")
    conn.commit()

    row = conn.execute(
        "SELECT * FROM geofences WHERE id = ?", (geofence_id,)
    ).fetchone()
    result = _geofence_row(row, conn)
    conn.close()
    return result


@app.delete("/api/geofences/{geofence_id}", status_code=204)
def delete_geofence(geofence_id: int):
    conn = get_db()
    existing = conn.execute(
        "SELECT id, name FROM geofences WHERE id = ?", (geofence_id,)
    ).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Geofence not found")
    conn.execute("DELETE FROM geofences WHERE id = ?", (geofence_id,))
    conn.commit()
    _log_activity(conn, "deleted", "geofence", geofence_id,
                  f"Geofence '{existing['name']}' deleted")
    conn.commit()
    conn.close()


# ─── Activity Feed API ──────────────────────────────────────────────────────


@app.get("/api/activity")
def list_activity(
    user_id: Optional[int] = Query(None),
    entity_type: Optional[str] = Query(None),
    date_from: Optional[str] = Query(None),
    date_to: Optional[str] = Query(None),
    limit: int = Query(100, ge=1, le=1000),
    offset: int = Query(0, ge=0),
):
    conn = get_db()
    conditions = []
    params = []

    if user_id is not None:
        conditions.append("a.user_id = ?")
        params.append(user_id)
    if entity_type is not None:
        conditions.append("a.entity_type = ?")
        params.append(entity_type)
    if date_from:
        conditions.append("a.created_at >= ?")
        params.append(date_from)
    if date_to:
        conditions.append("a.created_at <= ?")
        params.append(date_to + " 23:59:59")

    where = " AND ".join(conditions)
    sql = """SELECT a.*, u.username AS user_name
             FROM activity_log a
             LEFT JOIN users u ON a.user_id = u.id"""
    if where:
        sql += f" WHERE {where}"
    sql += " ORDER BY a.created_at DESC, a.id DESC LIMIT ? OFFSET ?"
    params.extend([limit, offset])

    rows = conn.execute(sql, params).fetchall()
    conn.close()
    return [row_to_dict(r) for r in rows]


# ─── Stats API ──────────────────────────────────────────────────────────────


@app.get("/api/stats")
def get_stats():
    conn = get_db()
    total_assets = conn.execute("SELECT COUNT(*) FROM assets").fetchone()[0]
    total_checkins = conn.execute("SELECT COUNT(*) FROM checkins").fetchone()[0]
    total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
    total_customers = conn.execute("SELECT COUNT(*) FROM customers").fetchone()[0]
    total_locations = conn.execute("SELECT COUNT(*) FROM locations").fetchone()[0]

    cats = conn.execute(
        "SELECT category, COUNT(*) AS cnt FROM assets GROUP BY category ORDER BY cnt DESC"
    ).fetchall()
    by_category = {r["category"]: r["cnt"] for r in cats}

    statuses = conn.execute(
        "SELECT status, COUNT(*) AS cnt FROM assets GROUP BY status ORDER BY cnt DESC"
    ).fetchall()
    by_status = {r["status"]: r["cnt"] for r in statuses}

    top_visited = conn.execute(
        """SELECT a.name, a.machine_id, COUNT(*) AS visit_count,
                  MAX(v.checkin_time) AS last_visit_date
           FROM visits v JOIN assets a ON v.asset_id = a.id
           GROUP BY v.asset_id ORDER BY visit_count DESC LIMIT 10"""
    ).fetchall()
    top_visited_list = [
        {"name": r["name"], "machine_id": r["machine_id"],
         "visit_count": r["visit_count"], "last_visit_date": r["last_visit_date"]}
        for r in top_visited
    ]

    time_on_site_rows = conn.execute(
        """SELECT u.username, COUNT(v.id) AS visit_count,
                  SUM(CASE WHEN v.checkout_time IS NOT NULL AND v.checkin_time IS NOT NULL
                      THEN (julianday(v.checkout_time) - julianday(v.checkin_time)) * 1440
                      ELSE 0 END) AS total_minutes
           FROM visits v JOIN users u ON v.user_id = u.id
           WHERE u.role LIKE '%technician%'
           GROUP BY v.user_id ORDER BY total_minutes DESC"""
    ).fetchall()
    time_on_site = [
        {"username": r["username"], "visit_count": r["visit_count"],
         "total_minutes": round(r["total_minutes"] or 0, 1)}
        for r in time_on_site_rows
    ]

    makes = conn.execute(
        "SELECT make, COUNT(*) AS cnt FROM assets WHERE make != '' GROUP BY make ORDER BY cnt DESC"
    ).fetchall()
    by_make = {r["make"]: r["cnt"] for r in makes}

    conn.close()
    return {
        "total_assets": total_assets,
        "total_checkins": total_checkins,
        "total_users": total_users,
        "total_customers": total_customers,
        "total_locations": total_locations,
        "by_category": by_category,
        "by_status": by_status,
        "top_visited": top_visited_list,
        "time_on_site": time_on_site,
        "by_make": by_make,
    }


# ─── CSV Export API ─────────────────────────────────────────────────────────


_ASSET_CSV_FIELDS = [
    "id", "machine_id", "serial_number", "name", "description", "category",
    "status", "make", "model", "address", "building_name", "building_number",
    "floor", "room", "trailer_number", "walking_directions", "map_link",
    "parking_location", "photo_path", "customer_id", "location_id",
    "assigned_to", "latitude", "longitude", "geofence_radius_meters",
    "created_at", "updated_at",
]

_CHECKIN_CSV_FIELDS = [
    "id", "asset_id", "user_id", "latitude", "longitude", "accuracy",
    "photo_path", "notes", "created_at",
]


def _generate_csv(rows, fieldnames):
    """Yield CSV rows as a string generator for StreamingResponse."""
    output = io.StringIO()
    writer = csv.DictWriter(output, fieldnames=fieldnames)
    writer.writeheader()
    yield output.getvalue()
    output.seek(0)
    output.truncate(0)
    for row in rows:
        writer.writerow(row_to_dict(row))
        yield output.getvalue()
        output.seek(0)
        output.truncate(0)


@app.get("/api/export/assets")
def export_assets_csv():
    conn = get_db()
    rows = conn.execute("SELECT * FROM assets ORDER BY created_at DESC").fetchall()
    conn.close()
    return StreamingResponse(
        _generate_csv(rows, _ASSET_CSV_FIELDS),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=assets.csv"},
    )


@app.get("/api/export/checkins")
def export_checkins_csv(asset_id: Optional[int] = Query(None)):
    conn = get_db()
    if asset_id is not None:
        rows = conn.execute(
            "SELECT * FROM checkins WHERE asset_id = ? ORDER BY created_at DESC, id DESC",
            (asset_id,),
        ).fetchall()
    else:
        rows = conn.execute("SELECT * FROM checkins ORDER BY created_at DESC").fetchall()
    conn.close()
    return StreamingResponse(
        _generate_csv(rows, _CHECKIN_CSV_FIELDS),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=checkins.csv"},
    )


# ─── Asset GPS Management ──────────────────────────────────────────────────


@app.get("/api/assets")
def list_assets_admin(
    q: Optional[str] = Query(None),
    status: Optional[str] = Query(None),
    category: Optional[str] = Query(None),
    customer_id: Optional[int] = Query(None),
    company: Optional[str] = Query(None),
    place: Optional[str] = Query(None),
    make: Optional[str] = Query(None),
    limit: int = Query(500, ge=1, le=5000),
    offset: int = Query(0, ge=0),
):
    """List all assets with optional filters and search."""
    conn = get_db()
    conditions = []
    params = []

    if q:
        conditions.append("(name LIKE ? OR machine_id LIKE ? OR company LIKE ? OR serial_number LIKE ?)")
        like = f"%{q}%"
        params.extend([like, like, like, like])
    if status:
        conditions.append("status = ?")
        params.append(status)
    if category:
        conditions.append("category = ?")
        params.append(category)
    if customer_id is not None:
        conditions.append("customer_id = ?")
        params.append(customer_id)
    if company:
        conditions.append("company = ?")
        params.append(company)
    if place:
        conditions.append("place = ?")
        params.append(place)
    if make:
        conditions.append("make = ?")
        params.append(make)

    where = " AND ".join(conditions)
    sql = "SELECT * FROM assets"
    count_sql = "SELECT COUNT(*) FROM assets"
    if where:
        sql += f" WHERE {where}"
        count_sql += f" WHERE {where}"

    total = conn.execute(count_sql, params).fetchone()[0]
    sql += " ORDER BY name ASC LIMIT ? OFFSET ?"
    rows = conn.execute(sql, params + [limit, offset]).fetchall()
    conn.close()

    result = [row_to_dict(r) for r in rows]
    return JSONResponse({
        "assets": result,
        "total": total,
        "limit": limit,
        "offset": offset,
    })


@app.put("/api/assets/{asset_id}")
def update_asset_admin(asset_id: int, body: dict):
    """Update any fields on an asset (admin). Body can include any asset column."""
    conn = get_db()
    existing = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Asset not found")

    # Whitelist of editable fields
    EDITABLE_FIELDS = {
        "name", "machine_id", "serial_number", "description", "category", "status",
        "make", "model", "address", "building_name", "building_number", "floor", "room",
        "trailer_number", "walking_directions", "map_link", "parking_location",
        "company", "place", "location_area",
        "install_date", "dex_report_date", "pulled_date", "deployed",
        "customer_id", "location_id", "assigned_to",
        "latitude", "longitude", "geofence_radius_meters",
    }

    updates = {}
    for field, val in body.items():
        if field not in EDITABLE_FIELDS:
            continue
        if val is not None:
            updates[field] = val
        else:
            updates[field] = None

    if not updates:
        conn.close()
        raise HTTPException(status_code=422, detail="No valid fields to update")

    if "category" in updates and updates["category"]:
        row = conn.execute("SELECT id FROM categories WHERE name = ?", (updates["category"],)).fetchone()
        if row is None:
            conn.close()
            raise HTTPException(status_code=422, detail=f"Invalid category '{updates['category']}'")

    if "status" in updates and updates["status"]:
        if updates["status"] not in VALID_STATUSES:
            conn.close()
            raise HTTPException(status_code=422, detail=f"Invalid status. Must be one of: {', '.join(sorted(VALID_STATUSES))}")

    updates["updated_at"] = "datetime('now')"
    set_clause = ", ".join(
        f"{k} = {v}" if k == "updated_at" else f"{k} = ?"
        for k, v in updates.items()
    )
    values = [v for k, v in updates.items() if k != "updated_at"]
    conn.execute(
        f"UPDATE assets SET {set_clause} WHERE id = ?",
        values + [asset_id],
    )
    conn.commit()

    row = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone()
    _log_activity(conn, "updated", "asset", asset_id,
                  f"Asset '{row['name']}' fields updated via admin")
    conn.commit()
    conn.close()
    return row_to_dict(row)


@app.get("/api/assets/search")
def search_assets_admin(machine_id: str = Query(...)):
    """Search for an asset by machine_id (admin)."""
    conn = get_db()
    row = conn.execute(
        "SELECT * FROM assets WHERE machine_id = ?", (machine_id,)
    ).fetchone()
    conn.close()
    if row is None:
        raise HTTPException(status_code=404, detail="Asset not found")
    return row_to_dict(row)


@app.get("/api/assets/{asset_id}")
def get_asset_admin(asset_id: int):
    """Get a single asset by ID (admin)."""
    conn = get_db()
    row = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone()
    conn.close()
    if row is None:
        raise HTTPException(status_code=404, detail="Asset not found")
    return row_to_dict(row)

@app.delete("/api/assets/{asset_id}/gps", status_code=204)
def clear_asset_gps(asset_id: int):
    """Clear GPS coordinates from an asset (sets latitude/longitude to NULL)."""
    conn = get_db()
    existing = conn.execute("SELECT id, name FROM assets WHERE id = ?", (asset_id,)).fetchone()
    if existing is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Asset not found")
    conn.execute(
        "UPDATE assets SET latitude = NULL, longitude = NULL, updated_at = datetime('now') WHERE id = ?",
        (asset_id,),
    )
    _log_activity(conn, "updated", "asset", asset_id,
                  f"GPS data cleared for '{existing['name']}'")
    conn.commit()
    conn.close()



# ─── Batch Status Update API ─────────────────────────────────────────────────


class BatchStatusUpdate(BaseModel):
    ids: list[int]
    status: str


@app.post("/api/assets/batch-status")
def batch_update_status(body: BatchStatusUpdate):
    """Update the status of multiple assets at once."""
    if not body.ids:
        raise HTTPException(status_code=422, detail="No asset IDs provided")
    if body.status not in VALID_STATUSES:
        raise HTTPException(
            status_code=422,
            detail=f"Invalid status. Must be one of: {', '.join(sorted(VALID_STATUSES))}",
        )

    conn = get_db()
    placeholders = ",".join(["?"] * len(body.ids))

    # Verify all assets exist
    existing = conn.execute(
        f"SELECT id, name FROM assets WHERE id IN ({placeholders})", body.ids
    ).fetchall()

    if len(existing) != len(body.ids):
        conn.close()
        raise HTTPException(status_code=404, detail="One or more asset IDs not found")

    # Update all assets
    conn.execute(
        f"UPDATE assets SET status = ?, updated_at = datetime('now') WHERE id IN ({placeholders})",
        [body.status] + body.ids,
    )
    _log_activity(conn, "updated", "asset", 0,
                  f"Batch status change: {len(body.ids)} assets set to '{body.status}'")
    conn.commit()
    conn.close()

    return {"updated": len(body.ids), "status": body.status}


class BatchAssetUpdate(BaseModel):
    ids: list[int]
    fields: dict  # { "column_name": value, ... }


@app.post("/api/assets/batch-update")
def batch_update_assets(body: BatchAssetUpdate):
    """Update the same fields on multiple assets at once. Used by Cleanup tool.
    
    Body: { ids: [1,2,3], fields: { make: "Crane", building_name: "B7", floor: "FL 1" } }
    Only updates non-null field values.
    """
    if not body.ids:
        raise HTTPException(status_code=422, detail="No asset IDs provided")
    if not body.fields:
        raise HTTPException(status_code=422, detail="No fields to update")

    ALLOWED_FIELDS = {
        "make", "model", "name", "company", "place", "building_name",
        "building_number", "floor", "room", "location_area",
        "address", "category", "status", "customer_name",
    }
    # Filter to allowed fields with non-null/empty values
    updates = {k: v for k, v in body.fields.items()
               if k in ALLOWED_FIELDS and v is not None and v != ""}
    if not updates:
        raise HTTPException(status_code=422, detail="No valid fields to update")

    conn = get_db()
    placeholders = ",".join("?" * len(body.ids))

    # Verify all IDs exist
    existing = conn.execute(
        f"SELECT COUNT(*) FROM assets WHERE id IN ({placeholders})",
        body.ids,
    ).fetchone()[0]
    if existing != len(body.ids):
        conn.close()
        raise HTTPException(status_code=404, detail="One or more asset IDs not found")

    set_clause = ", ".join(f"{col} = ?" for col in updates)
    values = list(updates.values())
    conn.execute(
        f"UPDATE assets SET {set_clause}, updated_at = datetime('now') "
        f"WHERE id IN ({placeholders})",
        values + body.ids,
    )
    _log_activity(conn, "updated", "asset", 0,
                  f"Batch update: {len(body.ids)} assets — {', '.join(updates.keys())}")
    conn.commit()
    conn.close()
    return {"updated": len(body.ids), "fields": list(updates.keys())}


# ─── Icon Upload API ────────────────────────────────────────────────────────


@app.post("/api/upload/icon", status_code=201)
async def upload_icon(file: UploadFile = File(...)):
    path = _save_upload(file, "icons", ICON_ALLOWED_EXTS, ICON_MAX_SIZE)
    return {"path": path}


# ─── Reset Database API ─────────────────────────────────────────────────────


@app.post("/api/reset-database")
def reset_database(request: Request):
    """Drop all data tables and recreate them. Requires admin role.
    
    Must include X-Confirm-Reset: yes header to prevent accidental triggers."""
    # Admin-only check
    user = getattr(request.state, "current_user", None)
    auth_skipped = os.environ.get("CANTEEN_SKIP_AUTH") == "1"
    if not auth_skipped and (not user or not roles_contain(user.get("role", ""), "admin")):
        raise HTTPException(status_code=403, detail="Only admins can reset the database")
    
    # Confirmation header safety check
    if request.headers.get("X-Confirm-Reset", "").lower() != "yes":
        raise HTTPException(
            status_code=400,
            detail="Must include X-Confirm-Reset: yes header to confirm database reset"
        )
    
    conn = get_db()
    try:
        # Disable FK checks for clean drop
        conn.execute("PRAGMA foreign_keys=OFF")
        username = user.get("username", "Unknown") if user else "Unknown"
        _log_activity(conn, "reset", "database", 0,
                      f"Database reset by '{username}'")
        conn.commit()
        tables = [
            "visits", "activity_log", "geofence_users", "geofences",
            "asset_badges", "asset_keys", "checkins", "sessions",
            "models", "makes", "badge_types", "key_types", "key_names",
            "categories", "rooms", "locations", "customer_contacts",
            "customers", "assets", "users", "settings",
        ]
        for t in tables:
            conn.execute(f"DROP TABLE IF EXISTS {t}")
        conn.execute("PRAGMA foreign_keys=ON")
        conn.commit()

        init_db(conn)
        conn.commit()
    except Exception as e:
        conn.close()
        raise HTTPException(status_code=500, detail=f"Reset failed: {str(e)}")
    conn.close()
    return {"detail": "Database reset complete — all tables recreated with seed data."}


# ─── Business Name Lookup (Google Maps via Playwright) ────────────────────

_playwright_browser = None

def _get_browser():
    """Lazy singleton Playwright browser (reuse across requests)."""
    global _playwright_browser
    if _playwright_browser is None or not _playwright_browser.is_connected():
        from playwright.sync_api import sync_playwright
        _pw = sync_playwright().start()
        _playwright_browser = _pw.chromium.launch(channel='chrome', headless=True)
    return _playwright_browser


def lookup_business_name(address: str) -> str:
    """Use headless Chrome + Google Maps to find the business at an address.
    
    Returns the business name from the 'At this place' section, or empty string.
    """
    if not address or not address.strip():
        return ""
    try:
        browser = _get_browser()
        context = browser.new_context(
            user_agent=(
                "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
            ),
            locale="en-US",
        )
        page = context.new_page()
        # Build the Google Maps search URL
        q = address.strip().replace(" ", "+")
        page.goto(f"https://www.google.com/maps/search/{q}", timeout=15000)
        # Wait for the page to settle — the "At this place" section appears
        page.wait_for_timeout(3000)

        # Try to find business name via several selectors
        business_name = ""
        try:
            # Method 1: article with aria-label in At this place section
            articles = page.query_selector_all('div[role="article"][aria-label]')
            for art in articles:
                label = art.get_attribute("aria-label") or ""
                label = label.strip()
                if label and label != address.strip():
                    business_name = label
                    break
        except Exception:
            pass

        if not business_name:
            try:
                # Method 2: look for the heading after "At this place"
                el = page.query_selector('h2:has-text("At this place") + div [role="article"]')
                if el:
                    business_name = (el.get_attribute("aria-label") or "")
            except Exception:
                pass

        if not business_name:
            try:
                # Method 3: page title sometimes has business name
                title = page.title()
                if title and "Google Maps" not in title:
                    business_name = title.replace(" - Google Maps", "").strip()
            except Exception:
                pass

        context.close()
        return business_name.strip()
    except Exception as e:
        print(f"[lookup] Error looking up '{address}': {e}")
        return ""


@app.post("/api/lookup-business")
def lookup_business(body: dict):
    """Look up business name at an address using Google Maps."""
    address = (body.get("address") or "").strip()
    if not address:
        raise HTTPException(status_code=422, detail="address is required")
    name = lookup_business_name(address)
    return {"address": address, "business_name": name}


@app.post("/api/lookup-business/batch")
def lookup_business_batch(body: dict):
    """Batch lookup — takes assets: [{id, address}] and returns results.
    
    Max 50 assets per call to keep response time reasonable.
    """
    assets = body.get("assets", [])
    if not assets:
        raise HTTPException(status_code=422, detail="assets array is required")
    # Limit to 50
    assets = assets[:50]
    results = []
    total = len(assets)
    for i, asset in enumerate(assets):
        aid = asset.get("id")
        addr = (asset.get("address") or "").strip()
        if not addr:
            results.append({"id": aid, "address": addr, "business_name": ""})
            continue
        name = lookup_business_name(addr)
        results.append({"id": aid, "address": addr, "business_name": name})
    return {"results": results, "total": total}


# ─── Machine Replacement History ───────────────────────────────────────────


@app.get("/api/admin/replacements/history")
def get_replacement_history():
    """Return historical machine replacement events.

    Detects replacements by:
    1) Parsing all sync batch diff_summaries for new_assets & removed_assets
       that share similar location names (same batch or adjacent batches).
    2) Cross-referencing assets grouped by location_id where one is active
       ("On") and another is inactive ("Incompatible", "Action Required"),
       suggesting a swap.
    """
    conn = get_db()
    try:
        db = conn  # alias for clarity

        # ── Method 1: Parse sync batch diff_summaries ──
        batch_replacements = []
        batches = db.execute(
            "SELECT id, created_at, diff_summary, status "
            "FROM cantaloupe_sync_batches ORDER BY id"
        ).fetchall()

        # All new_assets ever seen, keyed by batch id
        all_new_by_batch = {}
        all_removed_by_batch = {}
        for b in batches:
            diff = b["diff_summary"]
            if isinstance(diff, str):
                try:
                    diff = _json.loads(diff)
                except _json.JSONDecodeError:
                    continue
            if not isinstance(diff, dict):
                continue
            bid = b["id"]
            new_list = diff.get("new_assets", []) or []
            rem_list = diff.get("removed_assets", []) or []
            if new_list:
                all_new_by_batch[bid] = new_list
            if rem_list:
                all_removed_by_batch[bid] = rem_list

            # Also check inline replacements from diff
            inline_reps = diff.get("replacements", []) or []
            for rep in inline_reps:
                batch_replacements.append({
                    "batch_id": bid,
                    "batch_date": b["created_at"],
                    "batch_status": b["status"],
                    "old_machine_id": rep.get("old_machine_id", ""),
                    "new_machine_id": rep.get("new_machine_id", ""),
                    "location": rep.get("location_address", ""),
                    "customer": rep.get("customer_name", ""),
                    "old_name": rep.get("old_asset_name", ""),
                    "new_name": rep.get("new_asset_name", ""),
                    "confidence": rep.get("confidence", "unknown"),
                    "source": "inline_sync_replacements",
                })

        # Cross-reference: new_assets in batch N vs removed_assets in batch N (or N-1)
        batch_ids = sorted(set(list(all_new_by_batch.keys()) + list(all_removed_by_batch.keys())))
        for bid in batch_ids:
            new_items = all_new_by_batch.get(bid, [])
            rem_items = all_removed_by_batch.get(bid, [])

            # Also check previous batch for removals
            prev_rem_items = all_removed_by_batch.get(bid - 1, [])
            combined_rem = rem_items + prev_rem_items

            if not new_items or not combined_rem:
                continue

            # Build location name -> machines mapping
            new_by_loc = {}
            for n in new_items:
                loc = (n.get("location") or n.get("name", "")).strip().lower()
                if loc:
                    new_by_loc.setdefault(loc, []).append(n)

            rem_by_loc = {}
            for r in combined_rem:
                loc = (r.get("location") or r.get("name", "")).strip().lower()
                if loc:
                    rem_by_loc.setdefault(loc, []).append(r)

            # Try fuzzy match — share first 20+ chars
            for nloc, new_list in new_by_loc.items():
                for rloc, rem_list in rem_by_loc.items():
                    # Exact or prefix match
                    min_len = min(len(nloc), len(rloc))
                    if min_len < 10:
                        continue
                    match_ratio = sum(1 for a, b in zip(nloc, rloc) if a == b) / min_len
                    if match_ratio < 0.7:
                        continue

                    for new_item in new_list:
                        for rem_item in rem_list:
                            batch_replacements.append({
                                "batch_id": bid,
                                "batch_date": next(
                                    (b["created_at"] for b in batches if b["id"] == bid), ""),
                                "batch_status": next(
                                    (b["status"] for b in batches if b["id"] == bid), ""),
                                "old_machine_id": rem_item.get("machine_id", ""),
                                "new_machine_id": new_item.get("machine_id", ""),
                                "location": new_item.get("location") or new_item.get("name", ""),
                                "customer": new_item.get("customer", ""),
                                "old_name": rem_item.get("name", ""),
                                "new_name": new_item.get("name", ""),
                                "confidence": "medium" if match_ratio >= 0.85 else "low",
                                "source": "batch_diff_crossref",
                            })

        # ── Method 2: Location-group based (live assets at same location_id) ──
        # Only for small locations (2-5 machines) where it's likely a replacement,
        # not a multi-machine site like a large warehouse/hotel.
        loc_replacements = []
        locations_with_multi = db.execute("""
            SELECT location_id, COUNT(*) as cnt
            FROM assets
            WHERE location_id IS NOT NULL
            GROUP BY location_id
            HAVING cnt BETWEEN 2 AND 5
        """).fetchall()

        # Also get location names
        loc_names = {}
        loc_rows = db.execute("SELECT id, name FROM locations").fetchall()
        for lr in loc_rows:
            loc_names[lr["id"]] = lr["name"]

        for loc_row in locations_with_multi:
            lid = loc_row["location_id"]
            assets_at_loc = db.execute("""
                SELECT id, machine_id, name, status, created_at, updated_at,
                       install_date, pulled_date, deployed, building_name,
                       building_number, floor, room
                FROM assets
                WHERE location_id = ?
                ORDER BY machine_id
            """, (lid,)).fetchall()

            # Find pairs where one is active ("On") and another is inactive
            active = [a for a in assets_at_loc if a["status"] == "On"]
            inactive = [a for a in assets_at_loc
                        if a["status"] in ("Incompatible", "Off", "Action Required", "retired")]

            if not active or not inactive:
                continue

            # Only flag pairs where the building_name suggests the same physical spot
            for act in active:
                for inact in inactive:
                    # Check if they share building info (likely same spot)
                    same_building = (
                        act["building_name"] and inact["building_name"]
                        and act["building_name"].strip().lower() == inact["building_name"].strip().lower()
                    )
                    same_floor_room = (
                        act["floor"] and inact["floor"]
                        and act["floor"].strip() == inact["floor"].strip()
                        and act["room"] and inact["room"]
                        and act["room"].strip() == inact["room"].strip()
                    )

                    if not same_building and not same_floor_room:
                        continue

                    loc_name = loc_names.get(lid) or act["name"]
                    loc_replacements.append({
                        "batch_id": None,
                        "batch_date": None,
                        "batch_status": None,
                        "old_machine_id": inact["machine_id"],
                        "new_machine_id": act["machine_id"],
                        "location": loc_name,
                        "customer": "",
                        "old_name": inact["name"],
                        "new_name": act["name"],
                        "confidence": "low",
                        "source": "location_group",
                        "old_status": inact["status"],
                        "new_status": act["status"],
                    })

        # ── Merge & sort ──
        seen = set()
        all_reps = []

        for entry in batch_replacements + loc_replacements:
            key = (entry["old_machine_id"], entry["new_machine_id"])
            if key in seen:
                continue
            seen.add(key)
            all_reps.append(entry)

        # Sort: by batch_date descending, then by location
        all_reps.sort(key=lambda x: (
            x["batch_date"] or "9999-12-31",
            x["location"] or "",
        ), reverse=True)

        return {
            "replacements": all_reps,
            "total": len(all_reps),
            "sources": {
                "sync_batch_inline": len([r for r in all_reps if r["source"] == "inline_sync_replacements"]),
                "batch_crossref": len([r for r in all_reps if r["source"] == "batch_diff_crossref"]),
                "location_group": len([r for r in all_reps if r["source"] == "location_group"]),
            },
        }
    finally:
        conn.close()


# ═══════════════════════════════════════════════════════════════════════════
# WORK ORDER REPORTS — reads extraction DB directly
# ═══════════════════════════════════════════════════════════════════════════

EXTRACTION_PROJECT = Path.home() / "projects" / "ms-field-service-extraction"
EXTRACTION_DB = (
    EXTRACTION_PROJECT
    / "data-samples"
    / "msfs_backup"
    / "fieldservicecanteen.crm.dynamics.com_9fc6c50c-b097-f011-b4cb-7ced8d1b15a2_data.db"
)

STATUS_LABELS_REPORT = {
    "690970000": "Unscheduled",
    "690970001": "Scheduled",
    "690970002": "In Progress",
    "690970003": "Completed",
    "690970004": "Posted",
    "690970005": "Cancelled",
    "690959000": "Postponed",
}


def _get_extraction_db_report() -> sqlite3.Connection | None:
    """Return read-only connection to the MS Field Service extraction DB."""
    if not EXTRACTION_DB.exists():
        return None
    conn = sqlite3.connect(str(EXTRACTION_DB))
    conn.row_factory = sqlite3.Row
    return conn


def _status_label_report(code) -> str:
    return STATUS_LABELS_REPORT.get(str(code), f"Unknown ({code})")


@app.get("/api/admin/reports/workorder-kpis")
async def reports_workorder_kpis(
    date_from: str = Query("", description="Start date ISO (YYYY-MM-DD)"),
    date_to: str = Query("", description="End date ISO (YYYY-MM-DD)"),
    technician: str = Query("", description="Filter by technician name"),
    status: str = Query("", description="Filter by status code (comma-separated)"),
):
    """
    Return aggregated KPI data for field tech work orders.
    Filters: date_from, date_to (on createdon), technician, status.
    """
    conn = _get_extraction_db_report()
    if not conn:
        raise HTTPException(503, "Extraction DB not available")
    try:
        cur = conn.cursor()

        # ── Build WHERE clause ──
        where_parts = []
        params = []

        if date_from:
            where_parts.append("w.createdon >= ?")
            params.append(date_from)
        if date_to:
            where_parts.append("w.createdon < date(?, '+1 day')")
            params.append(date_to)
        if technician:
            tech_names = [t.strip() for t in technician.split(",") if t.strip()]
            if tech_names:
                tech_conditions = []
                for name in tech_names:
                    like = f"%{name}%"
                    tech_conditions.append(
                        "(COALESCE(w.\"hsl_bookedresource1!name\",'') LIKE ? "
                        "OR COALESCE(w.\"hsl_bookedresource2!name\",'') LIKE ? "
                        "OR COALESCE(w.\"hsl_bookedresource3!name\",'') LIKE ? "
                        "OR EXISTS (SELECT 1 FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" LIKE ?))"
                    )
                    params.extend([like, like, like, like])
                where_parts.append("(" + " OR ".join(tech_conditions) + ")")
        if status:
            codes = [s.strip() for s in status.split(",") if s.strip()]
            if codes:
                placeholders = ", ".join(["?"] * len(codes))
                where_parts.append(f"w.msdyn_systemstatus IN ({placeholders})")
                params.extend(codes)

        where_sql = " AND ".join(where_parts) if where_parts else "1=1"

        # ── KPI 1: Total WOs ──
        cur.execute(
            f"SELECT COUNT(*) FROM msdyn_workorder w WHERE {where_sql}", params
        )
        total_workorders = cur.fetchone()[0]

        # ── KPI 2: By Status ──
        cur.execute(
            f"""
            SELECT w.msdyn_systemstatus, COUNT(*) as cnt
            FROM msdyn_workorder w
            WHERE {where_sql}
            GROUP BY w.msdyn_systemstatus
            ORDER BY cnt DESC
            """,
            params,
        )
        by_status_raw = cur.fetchall()
        by_status = []
        for r in by_status_raw:
            code = str(r["msdyn_systemstatus"])
            by_status.append({
                "code": code,
                "label": _status_label_report(code),
                "count": r["cnt"],
            })

        # ── KPI 3: WOs Over Time (daily) ──
        cur.execute(
            f"""
            SELECT date(w.createdon) as day, COUNT(*) as cnt
            FROM msdyn_workorder w
            WHERE {where_sql}
            GROUP BY day
            ORDER BY day
            """,
            params,
        )
        over_time = [{"day": r["day"], "count": r["cnt"]} for r in cur.fetchall()]

        # ── KPI 4: By Technician ──
        cur.execute(
            f"""
            SELECT t.technician, COUNT(*) as cnt
            FROM (
                SELECT COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
                    (SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
                ) AS technician
                FROM msdyn_workorder w
                WHERE {where_sql}
            ) t
            WHERE t.technician IS NOT NULL AND t.technician != ''
            GROUP BY t.technician
            ORDER BY cnt DESC
            LIMIT 30
            """,
            params,
        )
        by_technician = [{"name": r["technician"], "count": r["cnt"]} for r in cur.fetchall()]

        # ── KPI 5: By Work Type ──
        cur.execute(
            f"""
            SELECT w.\"msdyn_workordertype!name\" AS work_type, COUNT(*) as cnt
            FROM msdyn_workorder w
            WHERE {where_sql}
            GROUP BY work_type
            ORDER BY cnt DESC
            """,
            params,
        )
        by_work_type = [{"name": r["work_type"] or "Unspecified", "count": r["cnt"]} for r in cur.fetchall()]

        # ── KPI 6: By Priority ──
        cur.execute(
            f"""
            SELECT w.\"msdyn_priority!name\" AS priority, COUNT(*) as cnt
            FROM msdyn_workorder w
            WHERE {where_sql}
            GROUP BY priority
            ORDER BY cnt DESC
            """,
            params,
        )
        by_priority = [{"name": r["priority"] or "Unspecified", "count": r["cnt"]} for r in cur.fetchall()]

        # ── KPI 7: Technician Performance (detailed) — use simpler subquery ──
        cur.execute(
            f"""
            SELECT
                t.technician,
                COUNT(*) as total,
                SUM(CASE WHEN t.msdyn_systemstatus IN (690970003, 690970004) THEN 1 ELSE 0 END) as completed,
                SUM(CASE WHEN t.msdyn_systemstatus = 690970002 THEN 1 ELSE 0 END) as in_progress,
                SUM(CASE WHEN t.msdyn_systemstatus = 690970001 THEN 1 ELSE 0 END) as scheduled
            FROM (
                SELECT
                    COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
                        (SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
                    ) AS technician,
                    w.msdyn_systemstatus
                FROM msdyn_workorder w
                WHERE {where_sql}
            ) t
            WHERE t.technician IS NOT NULL AND t.technician != ''
            GROUP BY t.technician
            ORDER BY total DESC
            LIMIT 30
            """,
            params,
        )
        tech_perf = []
        for r in cur.fetchall():
            tech_perf.append({
                "name": r["technician"],
                "total": r["total"],
                "completed": r["completed"],
                "in_progress": r["in_progress"],
                "scheduled": r["scheduled"],
            })

        # ── KPI 8: By Status per Tech (stacked) ──
        cur.execute(
            f"""
            SELECT
                t.technician,
                t.status_code,
                COUNT(*) as cnt
            FROM (
                SELECT
                    COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
                        (SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
                    ) AS technician,
                    w.msdyn_systemstatus AS status_code
                FROM msdyn_workorder w
                WHERE {where_sql}
            ) t
            WHERE t.technician IS NOT NULL AND t.technician != ''
            GROUP BY t.technician, t.status_code
            ORDER BY t.technician, t.status_code
            """,
            params,
        )
        status_by_tech = {}
        for r in cur.fetchall():
            tech = r["technician"]
            if tech not in status_by_tech:
                status_by_tech[tech] = {}
            code = str(r["status_code"])
            status_by_tech[tech][code] = r["cnt"]

        # ── KPI 9: Summary stats ──
        cur.execute(
            f"""
            SELECT
                COUNT(*) as total,
                SUM(CASE WHEN w.msdyn_systemstatus IN (690970003, 690970004) THEN 1 ELSE 0 END) as completed_posted,
                SUM(CASE WHEN w.msdyn_systemstatus = 690970002 THEN 1 ELSE 0 END) as in_progress,
                SUM(CASE WHEN w.msdyn_systemstatus = 690970001 THEN 1 ELSE 0 END) as scheduled,
                COUNT(DISTINCT COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
                    (SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
                )) as unique_techs
            FROM msdyn_workorder w
            WHERE {where_sql}
            """,
            params,
        )
        summary = dict(cur.fetchone())

        # ── KPI 10: Avg days to complete (for completed/posted) ──
        cur.execute(
            f"""
            SELECT AVG(julianday(w.msdyn_completedon) - julianday(w.createdon)) as avg_days
            FROM msdyn_workorder w
            WHERE {where_sql}
              AND w.msdyn_systemstatus IN (690970003, 690970004)
              AND w.msdyn_completedon IS NOT NULL
            """,
            params,
        )
        avg_days = cur.fetchone()[0]
        summary["avg_days_to_complete"] = round(avg_days, 1) if avg_days else None

        return {
            "total_workorders": total_workorders,
            "summary": summary,
            "by_status": by_status,
            "by_technician": by_technician,
            "by_work_type": by_work_type,
            "by_priority": by_priority,
            "over_time": over_time,
            "technician_performance": tech_perf,
            "status_by_tech": status_by_tech,
        }
    finally:
        conn.close()


# ═══════════════════════════════════════════════════════════════════════════
# WORK ORDERS LIST — reads extraction DB directly (same DB as reports)
# ═══════════════════════════════════════════════════════════════════════════


@app.get("/api/admin/workorders/list")
async def admin_workorders_list(
    q: str = Query("", description="Search term (WO#, account, city)"),
    status: str = Query("", description="Comma-separated status codes"),
    tech: str = Query("", description="Technician name filter"),
    date_from: str = Query("", description="Date filter start (YYYY-MM-DD)"),
    date_to: str = Query("", description="Date filter end (YYYY-MM-DD)"),
    limit: int = Query(50, ge=1, le=500),
    offset: int = Query(0, ge=0),
):
    """Fetch filtered, paginated work orders from the extraction DB."""
    conn = _get_extraction_db_report()
    if not conn:
        raise HTTPException(503, "Extraction DB not available")
    try:
        cur = conn.cursor()
        params: list = []
        where_clauses: list[str] = []

        if q:
            like = f"%{q}%"
            where_clauses.append(
                "(w.msdyn_name LIKE ? OR a.name LIKE ? OR a.address1_city LIKE ? OR w.\"msdyn_customerasset!name\" LIKE ? OR w.\"msdyn_serviceaccount!name\" LIKE ?)"
            )
            params.extend([like, like, like, like, like])

        if status:
            codes = [s.strip() for s in status.split(",") if s.strip()]
            if codes:
                placeholders = ",".join(["?" for _ in codes])
                where_clauses.append(f"w.msdyn_systemstatus IN ({placeholders})")
                params.extend(codes)

        if tech:
            like = f"%{tech}%"
            where_clauses.append(
                "(COALESCE(w.\"hsl_bookedresource1!name\",'') LIKE ? "
                "OR COALESCE(w.\"hsl_bookedresource2!name\",'') LIKE ? "
                "OR COALESCE(w.\"hsl_bookedresource3!name\",'') LIKE ? "
                "OR EXISTS (SELECT 1 FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" LIKE ?))"
            )
            params.extend([like, like, like, like])

        if date_from:
            where_clauses.append("w.createdon >= ?")
            params.append(date_from)
        if date_to:
            where_clauses.append("w.createdon < date(?, '+1 day')")
            params.append(date_to)

        where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"

        # Count
        cur.execute(
            f"SELECT COUNT(*) FROM msdyn_workorder w LEFT JOIN account a ON w.\"msdyn_serviceaccount!id\" = a.accountid WHERE {where_sql}",
            params,
        )
        total = cur.fetchone()[0]

        # Data
        data_sql = f"""
            SELECT
                w.msdyn_workorderid,
                w.msdyn_name,
                w."msdyn_serviceaccount!name" AS account_name,
                w.msdyn_workordersummary,
                w.createdon,
                w.msdyn_completedon,
                w.msdyn_systemstatus,
                w."msdyn_priority!name" AS priority,
                w."msdyn_workordertype!name" AS work_type,
                w."msdyn_primaryincidenttype!name" AS incident_type,
                w."msdyn_serviceterritory!name" AS territory,
                w."msdyn_customerasset!name" AS customer_asset_name,
                w.msdyn_city,
                w.msdyn_stateorprovince,
                (SELECT GROUP_CONCAT(DISTINCT t)
                 FROM (
                   SELECT b2."resource!name" AS t FROM bookableresourcebooking b2 WHERE b2."msdyn_workorder!name" = w.msdyn_name
                   UNION
                   SELECT w2."hsl_bookedresource1!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource1!name" IS NOT NULL
                   UNION
                   SELECT w2."hsl_bookedresource2!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource2!name" IS NOT NULL
                   UNION
                   SELECT w2."hsl_bookedresource3!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource3!name" IS NOT NULL
                 )) AS technicians,
                a.name AS account_display_name,
                a.address1_city,
                a.address1_stateorprovince
            FROM msdyn_workorder w
            LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
            WHERE {where_sql}
            ORDER BY w.createdon DESC, w.msdyn_name
            LIMIT ? OFFSET ?
        """
        data_params = params + [limit, offset]
        cur.execute(data_sql, data_params)
        rows = cur.fetchall()

        workorders = []
        for r in rows:
            workorders.append({
                "id": r["msdyn_workorderid"],
                "name": r["msdyn_name"],
                "account_name": r["account_name"],
                "account_display_name": r["account_display_name"],
                "summary": r["msdyn_workordersummary"],
                "status_code": str(r["msdyn_systemstatus"]),
                "status": _status_label_report(r["msdyn_systemstatus"]),
                "priority": r["priority"],
                "work_type": r["work_type"],
                "incident_type": r["incident_type"],
                "territory": r["territory"],
                "customer_asset_name": r["customer_asset_name"],
                "technicians": r["technicians"],
                "created_on": r["createdon"],
                "completed_on": r["msdyn_completedon"],
                "city": r["msdyn_city"] or r["address1_city"],
                "state": r["msdyn_stateorprovince"] or r["address1_stateorprovince"],
            })

        return {
            "results": workorders,
            "total": total,
            "offset": offset,
            "limit": limit,
        }
    finally:
        conn.close()


@app.get("/api/admin/workorders/lookup")
async def admin_workorders_lookup(name: str = Query("", description="Work order name/number")):
    """Return full details for a single work order by name."""
    if not name:
        raise HTTPException(400, "Work order name is required")
    conn = _get_extraction_db_report()
    if not conn:
        raise HTTPException(503, "Extraction DB not available")
    try:
        cur = conn.cursor()
        cur.execute("""
            SELECT
                w.*,
                a.name AS account_display_name,
                a.address1_line1,
                a.address1_city,
                a.address1_stateorprovince,
                a.address1_postalcode,
                a.telephone1,
                a.websiteurl,
                i.title AS incident_title,
                i.msdyn_servicerequesttype AS incident_type_code,
                (SELECT GROUP_CONCAT(DISTINCT t)
                 FROM (
                   SELECT b2."resource!name" AS t FROM bookableresourcebooking b2 WHERE b2."msdyn_workorder!name" = w.msdyn_name
                   UNION
                   SELECT w2."hsl_bookedresource1!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource1!name" IS NOT NULL
                   UNION
                   SELECT w2."hsl_bookedresource2!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource2!name" IS NOT NULL
                   UNION
                   SELECT w2."hsl_bookedresource3!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource3!name" IS NOT NULL
                 )) AS technicians
            FROM msdyn_workorder w
            LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
            LEFT JOIN msdyn_workorderincident woi ON woi."msdyn_workorder!id" = w.msdyn_workorderid
            LEFT JOIN incident i ON i.incidentid = woi."msdyn_incident!id"
            WHERE w.msdyn_name = ?
            LIMIT 1
        """, [name])
        row = cur.fetchone()
        if not row:
            raise HTTPException(404, f"Work order '{name}' not found")

        # Map row to dict (all columns)
        keys = [d[0] for d in cur.description]
        data = dict(zip(keys, row))

        # Add readable status
        data["status_label"] = _status_label_report(data.get("msdyn_systemstatus"))

        return {"found": True, "data": data}
    finally:
        conn.close()


@app.get("/api/admin/workorders/technicians")
async def admin_workorders_technicians():
    """Return list of all technicians found in work orders."""
    conn = _get_extraction_db_report()
    if not conn:
        raise HTTPException(503, "Extraction DB not available")
    try:
        cur = conn.cursor()
        cur.execute("""
            SELECT DISTINCT t FROM (
                SELECT b."resource!name" AS t FROM bookableresourcebooking b WHERE b."resource!name" IS NOT NULL AND b."resource!name" != ''
                UNION
                SELECT w."hsl_bookedresource1!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource1!name" IS NOT NULL AND w."hsl_bookedresource1!name" != ''
                UNION
                SELECT w."hsl_bookedresource2!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource2!name" IS NOT NULL AND w."hsl_bookedresource2!name" != ''
                UNION
                SELECT w."hsl_bookedresource3!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource3!name" IS NOT NULL AND w."hsl_bookedresource3!name" != ''
            ) ORDER BY t
        """)
        techs = [r[0] for r in cur.fetchall()]
        return {"technicians": techs}
    finally:
        conn.close()


@app.get("/api/admin/workorders/by-asset")
async def admin_workorders_by_asset(
    name: str = Query("", description="Asset name (msdyn_customerasset!name) to search work orders for"),
):
    """Return work orders linked to a specific customer asset by name."""
    if not name:
        raise HTTPException(400, "Asset name is required")
    conn = _get_extraction_db_report()
    if not conn:
        raise HTTPException(503, "Extraction DB not available")
    try:
        cur = conn.cursor()
        cur.execute("""
            SELECT
                w.msdyn_name, w."msdyn_customerasset!name" AS asset_name,
                w."msdyn_serviceaccount!name" AS account_name,
                w.msdyn_workordersummary,
                w."msdyn_workordertype!name" AS work_type,
                w."msdyn_priority!name" AS priority,
                w.msdyn_systemstatus,
                w.createdon, w.msdyn_completedon,
                a.name AS account_display_name,
                a.address1_city, a.address1_stateorprovince
            FROM msdyn_workorder w
            LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
            WHERE w."msdyn_customerasset!name" = ?
            ORDER BY w.createdon DESC
        """, [name])
        rows = cur.fetchall()
        workorders = []
        for r in rows:
            workorders.append({
                "name": r["msdyn_name"],
                "asset_name": r["asset_name"],
                "account_name": r["account_name"] or r["account_display_name"],
                "summary": r["msdyn_workordersummary"],
                "work_type": r["work_type"],
                "priority": r["priority"],
                "status_code": str(r["msdyn_systemstatus"]),
                "status": _status_label_report(r["msdyn_systemstatus"]),
                "created_on": r["createdon"],
                "completed_on": r["msdyn_completedon"],
                "city": r["address1_city"],
                "state": r["address1_stateorprovince"],
            })
        return {"results": workorders, "total": len(workorders)}
    finally:
        conn.close()


# ═══════════════════════════════════════════════════════════════════════════
# ADB MSFS SYNC — proxies to the extraction viewer's sync API on port 8910
# ═══════════════════════════════════════════════════════════════════════════

EXTRACTION_SYNC_BASE = "http://localhost:8910"


@app.get("/api/admin/msfs-sync/info")
async def admin_msfs_sync_info():
    """Return ADB connection status, last sync metadata, and DB info."""
    try:
        async with httpx.AsyncClient(timeout=10) as client:
            resp = await client.get(f"{EXTRACTION_SYNC_BASE}/api/sync/info")
            return resp.json()
    except httpx.RequestError as e:
        return {
            "adb_connected": False,
            "extraction_server": False,
            "error": f"Cannot reach extraction server on port 8910: {e}",
        }


@app.post("/api/admin/msfs-sync/start")
async def admin_msfs_sync_start():
    """Start a background ADB sync task."""
    try:
        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/start")
            return resp.json()
    except httpx.RequestError as e:
        raise HTTPException(502, f"Extraction server unreachable: {e}")


@app.get("/api/admin/msfs-sync/task/{task_id}")
async def admin_msfs_sync_task(task_id: str):
    """Poll status of a running sync task."""
    try:
        async with httpx.AsyncClient(timeout=10) as client:
            resp = await client.get(f"{EXTRACTION_SYNC_BASE}/api/sync/task/{task_id}")
            if resp.status_code == 404:
                raise HTTPException(404, "Task not found")
            return resp.json()
    except httpx.RequestError as e:
        raise HTTPException(502, f"Extraction server unreachable: {e}")


@app.post("/api/admin/msfs-sync/launch-app")
async def admin_msfs_sync_launch():
    """Launch MS Field Service app, navigate to Check for Updates via UI nav.

    Taps globe icon in top-right, waits 15s, taps Check for Updates button,
    then waits for the phone's offline DB to stabilize.
    """
    try:
        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/check-for-updates")
            return resp.json()
    except httpx.RequestError as e:
        raise HTTPException(502, f"Extraction server unreachable: {e}")


@app.post("/api/admin/msfs-sync/cancel/{task_id}")
async def admin_msfs_sync_cancel(task_id: str):
    """Cancel a running sync task."""
    try:
        async with httpx.AsyncClient(timeout=10) as client:
            resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/cancel/{task_id}")
            return resp.json()
    except httpx.RequestError as e:
        raise HTTPException(502, f"Extraction server unreachable: {e}")


# ─── SPA Frontend (catch-all — MUST be last route) ────────────────────────

FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"


@app.get("/")
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str = ""):
    """Serve the admin SPA — all routes return index.html."""
    if FRONTEND_INDEX.is_file():
        return FileResponse(str(FRONTEND_INDEX))
    return JSONResponse({"detail": "Frontend not found"}, status_code=404)
