"""
Project Locations Router

Handles monitoring locations (NRLs for sound, monitoring points for vibration)
and unit assignments within projects.
"""

from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from typing import Optional
import uuid
import json

from fastapi import UploadFile, File
import zipfile
import hashlib
import io
from pathlib import Path

from backend.database import get_db
from backend.models import (
    Project,
    ProjectType,
    ProjectModule,
    MonitoringLocation,
    UnitAssignment,
    RosterUnit,
    MonitoringSession,
    DataFile,
    UnitHistory,
    ScheduledAction,
)
from backend.templates_config import templates
from backend.utils.timezone import local_to_utc, utc_to_local  # noqa: F401

router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])


# ── Audit log helper ──────────────────────────────────────────────────────────
# Mirrors record_history() in roster_edit.py.  Kept local to avoid cross-router
# imports.  The four assignment endpoints below use this to write UnitHistory
# rows that the unit-detail deployment timeline (Phase 4) renders.

def _record_assignment_history(
    db: Session,
    unit_id: str,
    change_type: str,
    *,
    old_value: Optional[str] = None,
    new_value: Optional[str] = None,
    notes: Optional[str] = None,
) -> None:
    """Append a UnitHistory row for an assignment-lifecycle event.

    change_type values used:
      - assignment_created   — unit assigned to a location (new assignment)
      - assignment_ended     — unit unassigned / removed (assigned_until set)
      - assignment_swapped   — unit replaced by another at the same location
      - assignment_updated   — assignment dates / notes edited via PATCH

    Caller is responsible for db.commit().
    """
    db.add(UnitHistory(
        unit_id=unit_id,
        change_type=change_type,
        field_name="unit_assignment",
        old_value=old_value,
        new_value=new_value,
        changed_at=datetime.utcnow(),
        source="manual",
        notes=notes,
    ))


# ============================================================================
# Shared helpers
# ============================================================================

def _require_module(project, module_type: str, db: Session) -> None:
    """Raise 400 if the project does not have the given module enabled."""
    if not project:
        raise HTTPException(status_code=404, detail="Project not found.")
    exists = db.query(ProjectModule).filter_by(
        project_id=project.id, module_type=module_type, enabled=True
    ).first()
    if not exists:
        raise HTTPException(
            status_code=400,
            detail=f"This project does not have the {module_type.replace('_', ' ').title()} module enabled.",
        )


# ============================================================================
# Session period helpers
# ============================================================================

def _derive_period_type(dt: datetime) -> str:
    """
    Classify a session start time into one of four period types.
    Night = 22:00–07:00, Day = 07:00–22:00.
    Weekend = Saturday (5) or Sunday (6).
    """
    is_weekend = dt.weekday() >= 5
    is_night = dt.hour >= 22 or dt.hour < 7
    if is_weekend:
        return "weekend_night" if is_night else "weekend_day"
    return "weekday_night" if is_night else "weekday_day"


def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str:
    """Build a human-readable session label, e.g. 'NRL-1 — Sun 2/23 — Night'.
    Uses started_at date as-is; user can correct period_type in the wizard.
    """
    day_abbr = dt.strftime("%a")
    date_str = f"{dt.month}/{dt.day}"
    period_str = {
        "weekday_day":   "Day",
        "weekday_night": "Night",
        "weekend_day":   "Day",
        "weekend_night": "Night",
    }.get(period_type, "")
    parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p]
    return " — ".join(parts)


# ============================================================================
# Monitoring Locations CRUD
# ============================================================================

@router.get("/locations", response_class=HTMLResponse)
async def get_project_locations(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
    location_type: Optional[str] = Query(None),
):
    """
    Get all monitoring locations for a project.
    Returns HTML partial with location list, split into active + removed.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    query = db.query(MonitoringLocation).filter_by(project_id=project_id)

    # Filter by type if provided
    if location_type:
        query = query.filter_by(location_type=location_type)

    # Order by operator-set sort_order, then name as a stable tie-breaker.
    locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()

    # For vibration locations, fan out event counts via SFM concurrently
    # so the card layout can show "{N} events" instead of "Sessions: 0"
    # (sessions don't really exist for the watcher-forward pipeline).
    # Sound locations skip this and keep showing session counts.
    event_counts: dict[str, int] = {}
    vibration_locations = [l for l in locations if l.location_type == "vibration"]
    if vibration_locations:
        import asyncio
        from backend.services.sfm_events import events_for_location
        results = await asyncio.gather(
            *(events_for_location(db, l.id, limit=1) for l in vibration_locations),
            return_exceptions=True,
        )
        for loc, res in zip(vibration_locations, results):
            if isinstance(res, Exception):
                continue   # leave event_counts[loc.id] unset → template falls back
            event_counts[loc.id] = (res.get("stats") or {}).get("event_count", 0) or 0

    # Enrich with assignment info, splitting active vs removed.
    active_data:  list = []
    removed_data: list = []
    for location in locations:
        # Get active assignment (active = assigned_until IS NULL).  For
        # removed locations this will normally be None because the
        # /remove cascade closes them, but check anyway for resilience
        # against legacy data.
        assignment = db.query(UnitAssignment).filter(
            and_(
                UnitAssignment.location_id == location.id,
                UnitAssignment.assigned_until == None,
            )
        ).first()

        assigned_unit = None
        if assignment:
            assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()

        # Count monitoring sessions
        session_count = db.query(MonitoringSession).filter_by(
            location_id=location.id
        ).count()

        item = {
            "location":      location,
            "assignment":    assignment,
            "assigned_unit": assigned_unit,
            "session_count": session_count,
        }
        if location.id in event_counts:
            item["event_count"] = event_counts[location.id]
        if location.removed_at is None:
            active_data.append(item)
        else:
            removed_data.append(item)

    return templates.TemplateResponse("partials/projects/location_list.html", {
        "request":            request,
        "project":            project,
        "locations":          active_data,           # back-compat alias
        "active_locations":   active_data,
        "removed_locations":  removed_data,
    })


@router.get("/locations-json")
async def get_project_locations_json(
    project_id: str,
    db: Session = Depends(get_db),
    location_type: Optional[str] = Query(None),
    include_removed: bool = Query(False),
):
    """
    Get all monitoring locations for a project as JSON.
    Used by the schedule modal to populate location dropdown.

    Removed locations are filtered out by default (you can't schedule
    a new action at a removed location).  Pass `include_removed=true`
    to get them too — useful for historical / reporting views.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    query = db.query(MonitoringLocation).filter_by(project_id=project_id)

    if location_type:
        query = query.filter_by(location_type=location_type)

    if not include_removed:
        query = query.filter(MonitoringLocation.removed_at == None)   # noqa: E711

    locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()

    return [
        {
            "id":             loc.id,
            "name":           loc.name,
            "location_type":  loc.location_type,
            "description":    loc.description,
            "address":        loc.address,
            "coordinates":    loc.coordinates,
            "removed_at":     loc.removed_at.isoformat() if loc.removed_at else None,
            "removal_reason": loc.removal_reason,
        }
        for loc in locations
    ]


@router.get("/locations-with-assignments")
async def get_locations_with_assignments(
    project_id: str,
    db: Session = Depends(get_db),
    location_type: Optional[str] = Query(None),
):
    """
    Locations + their currently-active assignment + current unit + paired modem
    in one call.  Used by the Unit Swap tool's location picker so a field tech
    can see what's deployed where without N+1 round-trips.

    Empty locations come back with assignment/unit/modem all null.
    Removed locations are always excluded — you don't swap onto a dead slot.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    query = db.query(MonitoringLocation).filter_by(project_id=project_id).filter(
        MonitoringLocation.removed_at == None  # noqa: E711
    )
    if location_type:
        query = query.filter_by(location_type=location_type)
    locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()

    results = []
    for loc in locations:
        assignment = db.query(UnitAssignment).filter(
            and_(
                UnitAssignment.location_id == loc.id,
                UnitAssignment.assigned_until == None,  # noqa: E711
            )
        ).first()

        unit_payload = None
        modem_payload = None
        if assignment:
            unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
            if unit:
                unit_payload = {
                    "id":           unit.id,
                    "device_type":  unit.device_type,
                    "unit_type":    unit.unit_type,
                    "slm_model":    unit.slm_model,
                    "deployed_with_modem_id": unit.deployed_with_modem_id,
                }
                if unit.deployed_with_modem_id:
                    modem = db.query(RosterUnit).filter_by(
                        id=unit.deployed_with_modem_id, device_type="modem"
                    ).first()
                    if modem:
                        modem_payload = {
                            "id":             modem.id,
                            "hardware_model": modem.hardware_model,
                            "ip_address":     modem.ip_address,
                            "phone_number":   modem.phone_number,
                            "deployed":       bool(modem.deployed),
                        }

        results.append({
            "id":            loc.id,
            "name":          loc.name,
            "location_type": loc.location_type,
            "description":   loc.description,
            "address":       loc.address,
            "coordinates":   loc.coordinates,
            "assignment": {
                "id":          assignment.id,
                "assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
                "notes":       assignment.notes,
            } if assignment else None,
            "unit":  unit_payload,
            "modem": modem_payload,
        })

    return results


@router.post("/locations/create")
async def create_location(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Create a new monitoring location within a project.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    form_data = await request.form()

    # Compute next sort_order so new locations land at the END of the
    # project's list rather than getting interleaved alphabetically.
    from sqlalchemy import func
    max_sort = db.query(func.max(MonitoringLocation.sort_order))\
        .filter_by(project_id=project_id).scalar()
    next_sort_order = (max_sort or 0) + 1 if max_sort is not None else 0

    location = MonitoringLocation(
        id=str(uuid.uuid4()),
        project_id=project_id,
        location_type=form_data.get("location_type"),
        name=form_data.get("name"),
        description=form_data.get("description"),
        coordinates=form_data.get("coordinates"),
        address=form_data.get("address"),
        location_metadata=form_data.get("location_metadata"),  # JSON string
        sort_order=next_sort_order,
    )

    db.add(location)
    db.commit()
    db.refresh(location)

    return JSONResponse({
        "success": True,
        "location_id": location.id,
        "message": f"Location '{location.name}' created successfully",
    })


@router.put("/locations/{location_id}")
async def update_location(
    project_id: str,
    location_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Update a monitoring location.
    """
    location = db.query(MonitoringLocation).filter_by(
        id=location_id,
        project_id=project_id,
    ).first()

    if not location:
        raise HTTPException(status_code=404, detail="Location not found")

    data = await request.json()

    # Update fields if provided
    if "name" in data:
        location.name = data["name"]
    if "description" in data:
        location.description = data["description"]
    if "location_type" in data:
        location.location_type = data["location_type"]
    if "coordinates" in data:
        location.coordinates = data["coordinates"]
    if "address" in data:
        location.address = data["address"]
    if "location_metadata" in data:
        location.location_metadata = data["location_metadata"]

    location.updated_at = datetime.utcnow()

    db.commit()

    return {"success": True, "message": "Location updated successfully"}


@router.delete("/locations/{location_id}")
async def delete_location(
    project_id: str,
    location_id: str,
    db: Session = Depends(get_db),
):
    """
    Delete a monitoring location.
    """
    location = db.query(MonitoringLocation).filter_by(
        id=location_id,
        project_id=project_id,
    ).first()

    if not location:
        raise HTTPException(status_code=404, detail="Location not found")

    # Check if location has active assignments (active = assigned_until IS NULL)
    active_assignments = db.query(UnitAssignment).filter(
        and_(
            UnitAssignment.location_id == location_id,
            UnitAssignment.assigned_until == None,
        )
    ).count()

    if active_assignments > 0:
        raise HTTPException(
            status_code=400,
            detail="Cannot delete location with active unit assignments. Unassign units first.",
        )

    db.delete(location)
    db.commit()

    return {"success": True, "message": "Location deleted successfully"}


@router.post("/locations/reorder")
async def reorder_locations(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Persist a new sort order for a project's monitoring locations.

    Body JSON: { "location_ids": [uuid, uuid, ...] }
        The list MUST contain location ids in the desired display order.
        Locations not included in the list keep their current sort_order
        (useful for the "active locations only — leave removed alone"
        drag-and-drop UX).

    Updates `sort_order` to the index of each id in the list.  Ties
    between included and excluded locations fall back to the existing
    sort_order.
    """
    try:
        payload = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid JSON body")

    ids = payload.get("location_ids") or []
    if not isinstance(ids, list) or len(ids) == 0:
        raise HTTPException(status_code=400, detail="location_ids must be a non-empty list")

    # Fetch only the locations being reordered and validate ownership.
    locations = db.query(MonitoringLocation).filter(
        MonitoringLocation.project_id == project_id,
        MonitoringLocation.id.in_(ids),
    ).all()

    found_ids = {l.id for l in locations}
    missing = [i for i in ids if i not in found_ids]
    if missing:
        raise HTTPException(
            status_code=404,
            detail=f"Some locations not found in this project: {missing[:3]}…",
        )

    # Apply 0-indexed sort_order matching the operator's chosen order.
    by_id = {l.id: l for l in locations}
    for idx, loc_id in enumerate(ids):
        by_id[loc_id].sort_order = idx

    db.commit()
    return {"success": True, "reordered": len(ids)}


@router.post("/locations/{location_id}/remove")
async def remove_location(
    project_id: str,
    location_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Soft-remove a monitoring location — mark it as no longer actively
    monitored without destroying it.

    Use case: a client drops a location from scope mid-project, but the
    historical events recorded there should remain attributed.  Deleting
    would orphan those events; this preserves them.

    Cascading side-effects:
      1. All active UnitAssignment rows at this location are closed
         (assigned_until = effective_date, status = "completed").
         Units become available for other deployments.
      2. All pending ScheduledAction rows at this location are cancelled
         (execution_status = "cancelled").
      3. Historical events stay attributed (attribution is window-based;
         events with timestamp < effective_date still match the
         now-closed assignment windows).

    Accepts JSON body:
      - effective_date: ISO datetime (optional, defaults to now)
      - reason:         operator note (optional)
    """
    location = db.query(MonitoringLocation).filter_by(
        id=location_id,
        project_id=project_id,
    ).first()

    if not location:
        raise HTTPException(status_code=404, detail="Location not found")

    if location.removed_at is not None:
        raise HTTPException(
            status_code=400,
            detail=f"Location is already removed (as of {location.removed_at.isoformat()}).",
        )

    # Body is optional — POST with no body is fine and means "remove now,
    # no reason given."
    try:
        payload = await request.json()
    except Exception:
        payload = {}

    # Effective date: accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or
    # full ISO.  Defaults to now if absent/empty.
    raw_eff = payload.get("effective_date")
    if raw_eff:
        try:
            effective_date = datetime.fromisoformat(raw_eff)
        except (TypeError, ValueError):
            raise HTTPException(
                status_code=400,
                detail=f"Invalid effective_date: {raw_eff!r}",
            )
    else:
        effective_date = datetime.utcnow()

    reason = (payload.get("reason") or "").strip() or None

    # 1. Close active assignments at this location.
    active_assignments = db.query(UnitAssignment).filter(
        and_(
            UnitAssignment.location_id == location_id,
            UnitAssignment.assigned_until == None,    # noqa: E711 — SQL NULL
        )
    ).all()

    for a in active_assignments:
        a.status         = "completed"
        a.assigned_until = effective_date
        _record_assignment_history(
            db,
            unit_id=a.unit_id,
            change_type="assignment_ended",
            old_value=location.name,
            new_value="location removed",
            notes=f"Location '{location.name}' marked as removed"
                  + (f" — {reason}" if reason else ""),
        )

    # 2. Cancel pending scheduled actions at this location.
    pending_actions = db.query(ScheduledAction).filter(
        and_(
            ScheduledAction.location_id == location_id,
            ScheduledAction.execution_status == "pending",
            ScheduledAction.scheduled_time >= effective_date,
        )
    ).all()

    for sa in pending_actions:
        sa.execution_status = "cancelled"
        sa.error_message = (
            f"Cancelled: location '{location.name}' marked as removed"
            + (f" — {reason}" if reason else "")
        )

    # 3. Mark the location itself as removed.
    location.removed_at     = effective_date
    location.removal_reason = reason
    location.updated_at     = datetime.utcnow()

    db.commit()

    return {
        "success":               True,
        "message":               f"Location '{location.name}' marked as removed",
        "effective_date":        effective_date.isoformat(),
        "assignments_closed":    len(active_assignments),
        "actions_cancelled":     len(pending_actions),
    }


@router.post("/locations/{location_id}/restore")
async def restore_location(
    project_id: str,
    location_id: str,
    db: Session = Depends(get_db),
):
    """
    Restore a previously-removed monitoring location to active.

    Clears `removed_at` and `removal_reason`.  Does NOT automatically
    re-open the assignments or scheduled actions that were closed when
    the location was removed — those stay closed and the operator can
    create new ones if they want to resume monitoring.
    """
    location = db.query(MonitoringLocation).filter_by(
        id=location_id,
        project_id=project_id,
    ).first()

    if not location:
        raise HTTPException(status_code=404, detail="Location not found")

    if location.removed_at is None:
        raise HTTPException(
            status_code=400,
            detail="Location is already active.",
        )

    location.removed_at     = None
    location.removal_reason = None
    location.updated_at     = datetime.utcnow()

    db.commit()

    return {
        "success": True,
        "message": f"Location '{location.name}' restored to active",
    }


# ============================================================================
# Unit Assignments
# ============================================================================

@router.get("/assignments", response_class=HTMLResponse)
async def get_project_assignments(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
    status: Optional[str] = Query("active"),
):
    """
    Get all unit assignments for a project.
    Returns HTML partial with assignment list.
    """
    query = db.query(UnitAssignment).filter_by(project_id=project_id)

    if status:
        query = query.filter_by(status=status)

    assignments = query.order_by(UnitAssignment.assigned_at.desc()).all()

    # Enrich with unit and location details
    assignments_data = []
    for assignment in assignments:
        unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
        location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()

        assignments_data.append({
            "assignment": assignment,
            "unit": unit,
            "location": location,
        })

    return templates.TemplateResponse("partials/projects/assignment_list.html", {
        "request": request,
        "project_id": project_id,
        "assignments": assignments_data,
    })


@router.post("/locations/{location_id}/assign")
async def assign_unit_to_location(
    project_id: str,
    location_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Assign a unit to a monitoring location.

    Accepts form fields:
      - unit_id        — required
      - assigned_at    — optional ISO datetime; defaults to now.  Set this
                         when backfilling a historical deployment whose
                         events landed in the orphan bucket.
      - assigned_until — optional ISO datetime; absent = open-ended /
                         active.
      - notes          — optional free text

    Refuses only when the *new window would overlap* an existing active
    open-ended assignment at the same location.  Closed historical windows
    that don't overlap are allowed (and required for orphan-event backfill).
    """
    location = db.query(MonitoringLocation).filter_by(
        id=location_id,
        project_id=project_id,
    ).first()

    if not location:
        raise HTTPException(status_code=404, detail="Location not found")

    form_data = await request.form()
    unit_id = form_data.get("unit_id")

    # Verify unit exists and matches location type
    unit = db.query(RosterUnit).filter_by(id=unit_id).first()
    if not unit:
        raise HTTPException(status_code=404, detail="Unit not found")

    # Check device type matches location type
    expected_device_type = "slm" if location.location_type == "sound" else "seismograph"
    if unit.device_type != expected_device_type:
        raise HTTPException(
            status_code=400,
            detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
        )

    # Parse dates.  Naive datetimes from datetime-local inputs are
    # interpreted as user-local and converted to UTC for storage; explicit
    # tz-aware ISO strings (Z / +00:00) skip the conversion.
    def _parse_user_dt(s: str | None, field: str):
        if not s:
            return None
        try:
            parsed = datetime.fromisoformat(s)
        except (TypeError, ValueError):
            raise HTTPException(status_code=400, detail=f"Invalid {field}: {s!r}")
        if parsed.tzinfo is None:
            return local_to_utc(parsed)
        return parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)

    assigned_at    = _parse_user_dt(form_data.get("assigned_at"),    "assigned_at") or datetime.utcnow()
    assigned_until = _parse_user_dt(form_data.get("assigned_until"), "assigned_until")
    if assigned_until is not None and assigned_until <= assigned_at:
        raise HTTPException(
            status_code=400, detail="assigned_until must be after assigned_at.",
        )

    # Reject only if the new window overlaps an existing assignment at the
    # SAME location.  Closed historical windows that sit before the current
    # active assignment are fine — that's the backfill case.
    new_end_for_overlap = assigned_until or datetime.utcnow()
    existing = db.query(UnitAssignment).filter(
        UnitAssignment.location_id == location_id
    ).all()
    for other in existing:
        other_start = other.assigned_at
        other_end   = other.assigned_until or datetime.utcnow()
        if assigned_at < other_end and new_end_for_overlap > other_start:
            other_window = (
                f"{other.assigned_at:%Y-%m-%d}"
                + (f" → {other.assigned_until:%Y-%m-%d}" if other.assigned_until else " → present")
            )
            raise HTTPException(
                status_code=400,
                detail=(
                    f"New window overlaps an existing assignment at this "
                    f"location ({other.unit_id} {other_window}). Use swap or "
                    f"edit that record instead."
                ),
            )

    assignment = UnitAssignment(
        id=str(uuid.uuid4()),
        unit_id=unit_id,
        location_id=location_id,
        project_id=project_id,
        device_type=unit.device_type,
        assigned_at=assigned_at,
        assigned_until=assigned_until,
        status="active" if assigned_until is None else "completed",
        notes=form_data.get("notes"),
    )

    db.add(assignment)
    _record_assignment_history(
        db,
        unit_id=unit_id,
        change_type="assignment_created",
        new_value=f"{location.name} (project: {location.project_id})",
        notes=form_data.get("notes"),
    )
    db.commit()
    db.refresh(assignment)

    return JSONResponse({
        "success": True,
        "assignment_id": assignment.id,
        "message": f"Unit '{unit_id}' assigned to '{location.name}'",
    })


@router.post("/assignments/{assignment_id}/unassign")
async def unassign_unit(
    project_id: str,
    assignment_id: str,
    db: Session = Depends(get_db),
):
    """
    Unassign a unit from a location.
    """
    assignment = db.query(UnitAssignment).filter_by(
        id=assignment_id,
        project_id=project_id,
    ).first()

    if not assignment:
        raise HTTPException(status_code=404, detail="Assignment not found")

    # Check if there are active monitoring sessions
    active_sessions = db.query(MonitoringSession).filter(
        and_(
            MonitoringSession.location_id == assignment.location_id,
            MonitoringSession.unit_id == assignment.unit_id,
            MonitoringSession.status == "recording",
        )
    ).count()

    if active_sessions > 0:
        raise HTTPException(
            status_code=400,
            detail="Cannot unassign unit with active monitoring sessions. Stop monitoring first.",
        )

    assignment.status = "completed"
    assignment.assigned_until = datetime.utcnow()

    # Unit is leaving the field — bench it so the heartbeat / polling
    # subsystem stops chasing it.  Also break the modem pairing both ways.
    unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
    if unit:
        if unit.deployed_with_modem_id:
            modem = db.query(RosterUnit).filter_by(
                id=unit.deployed_with_modem_id, device_type="modem"
            ).first()
            if modem and modem.deployed_with_unit_id == unit.id:
                modem.deployed_with_unit_id = None
            unit.deployed_with_modem_id = None
        if unit.deployed:
            unit.deployed = False

    location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
    _record_assignment_history(
        db,
        unit_id=assignment.unit_id,
        change_type="assignment_ended",
        old_value=location.name if location else assignment.location_id,
        new_value="unassigned",
    )

    db.commit()

    return {"success": True, "message": "Unit unassigned successfully"}


@router.patch("/assignments/{assignment_id}")
async def update_assignment(
    project_id: str,
    assignment_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Update an assignment's date window and/or notes.

    Common use case: backdate a deployment so events emitted before the
    operator created the assignment in terra-view (e.g. a unit that was
    physically deployed in December but only recorded in the system today)
    get correctly attributed to the location.

    Accepts JSON body with optional fields:
      - assigned_at:    ISO datetime (or empty string to leave unchanged)
      - assigned_until: ISO datetime, or null/"" to mark indefinite (active)
      - notes:          string

    Naive datetimes (no tz suffix) are interpreted as the user's
    configured timezone and converted to UTC for storage.  Send an
    explicit "+00:00" / "Z" suffix to skip the conversion (programmatic
    callers that already have UTC).

    Sets `status` to "active" when assigned_until is cleared, "completed"
    when it's set in the past.
    """
    assignment = db.query(UnitAssignment).filter_by(
        id=assignment_id,
        project_id=project_id,
    ).first()

    if not assignment:
        raise HTTPException(status_code=404, detail="Assignment not found")

    try:
        payload = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid JSON body")

    # Parse new values (None = unchanged, explicit None/"" for assigned_until = clear)
    new_assigned_at = assignment.assigned_at
    new_assigned_until = assignment.assigned_until
    new_notes = assignment.notes

    if "assigned_at" in payload:
        raw = payload["assigned_at"]
        if raw is None or raw == "":
            raise HTTPException(
                status_code=400,
                detail="assigned_at is required; cannot be cleared.",
            )
        try:
            # Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
            parsed = datetime.fromisoformat(raw)
        except (TypeError, ValueError):
            raise HTTPException(
                status_code=400,
                detail=f"Invalid assigned_at datetime: {raw!r}",
            )
        # Naive (no tz) → treat as user's local time and store as UTC.
        new_assigned_at = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)

    if "assigned_until" in payload:
        raw = payload["assigned_until"]
        if raw is None or raw == "":
            new_assigned_until = None
        else:
            try:
                parsed = datetime.fromisoformat(raw)
            except (TypeError, ValueError):
                raise HTTPException(
                    status_code=400,
                    detail=f"Invalid assigned_until datetime: {raw!r}",
                )
            new_assigned_until = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)

    if "notes" in payload:
        raw = payload["notes"]
        new_notes = (raw or "").strip() or None

    # Validation: end must be after start if both set.
    if new_assigned_until is not None and new_assigned_until <= new_assigned_at:
        raise HTTPException(
            status_code=400,
            detail="assigned_until must be after assigned_at.",
        )

    # Sanity: reject creating an overlap with another assignment of the SAME
    # unit at the SAME location.  Different units at the same location can
    # legitimately overlap during a swap window (rare but valid).
    new_end_for_overlap = new_assigned_until or datetime.utcnow()
    overlapping = (
        db.query(UnitAssignment)
        .filter(UnitAssignment.location_id == assignment.location_id)
        .filter(UnitAssignment.unit_id == assignment.unit_id)
        .filter(UnitAssignment.id != assignment.id)
        .all()
    )
    for other in overlapping:
        other_start = other.assigned_at
        other_end = other.assigned_until or datetime.utcnow()
        if new_assigned_at < other_end and new_end_for_overlap > other_start:
            raise HTTPException(
                status_code=400,
                detail=(
                    f"This window overlaps with another assignment for the "
                    f"same unit ({other.assigned_at:%Y-%m-%d} → "
                    f"{other.assigned_until and other.assigned_until.strftime('%Y-%m-%d') or 'present'})."
                ),
            )

    # Capture change description for audit log BEFORE mutating.
    old_start = assignment.assigned_at.isoformat() if assignment.assigned_at else None
    old_end   = assignment.assigned_until.isoformat() if assignment.assigned_until else "active"
    new_start = new_assigned_at.isoformat() if new_assigned_at else None
    new_end   = new_assigned_until.isoformat() if new_assigned_until else "active"

    # Apply.
    assignment.assigned_at = new_assigned_at
    assignment.assigned_until = new_assigned_until
    assignment.notes = new_notes
    assignment.status = "completed" if new_assigned_until is not None else "active"

    if old_start != new_start or old_end != new_end:
        _record_assignment_history(
            db,
            unit_id=assignment.unit_id,
            change_type="assignment_updated",
            old_value=f"{old_start} → {old_end}",
            new_value=f"{new_start} → {new_end}",
            notes=new_notes,
        )

    db.commit()
    db.refresh(assignment)

    return {
        "success": True,
        "assignment": {
            "id":             assignment.id,
            "unit_id":        assignment.unit_id,
            "location_id":    assignment.location_id,
            "assigned_at":    assignment.assigned_at.isoformat() if assignment.assigned_at else None,
            "assigned_until": assignment.assigned_until.isoformat() if assignment.assigned_until else None,
            "status":         assignment.status,
            "notes":          assignment.notes,
        },
    }


@router.delete("/assignments/{assignment_id}")
async def delete_assignment(
    project_id: str,
    assignment_id: str,
    db: Session = Depends(get_db),
):
    """
    Hard-delete an assignment record.

    Use case: operator clicked Assign by mistake (or 8 times in a row) and
    wants the bogus records gone — not just closed with an `assigned_until`
    timestamp.  The standard close-via-unassign path is for legitimate
    deployments that ended; this is for mis-clicks that never actually
    happened.

    Safety:
      - Refuses if any MonitoringSession exists for the same (unit, location)
        within this assignment's window — that suggests the deployment was
        real, and the operator should use unassign instead.
      - Refuses if the assignment is the ONLY active assignment for a unit
        currently shown as deployed AND a recording session is in progress.

    Audit:
      - Records UnitHistory `assignment_deleted` so the unit's deployment
        timeline shows the deletion happened (even though the row itself
        is gone).
    """
    assignment = db.query(UnitAssignment).filter_by(
        id=assignment_id,
        project_id=project_id,
    ).first()

    if not assignment:
        raise HTTPException(status_code=404, detail="Assignment not found")

    # Safety: is there a real recording history for this (unit, location)
    # within the assignment's time window?  If so, this isn't a mis-click —
    # the operator should close it via unassign, not delete it.
    window_start = assignment.assigned_at
    window_end   = assignment.assigned_until or datetime.utcnow()
    real_sessions = db.query(MonitoringSession).filter(
        and_(
            MonitoringSession.location_id == assignment.location_id,
            MonitoringSession.unit_id == assignment.unit_id,
            MonitoringSession.started_at >= window_start,
            MonitoringSession.started_at <= window_end,
        )
    ).count()

    if real_sessions > 0:
        raise HTTPException(
            status_code=400,
            detail=(
                f"Cannot delete this assignment — {real_sessions} monitoring "
                f"session(s) were recorded under it.  Use Unassign to close "
                f"the window instead, which preserves the audit trail."
            ),
        )

    # Resolve location name for audit log before deletion.
    location = db.query(MonitoringLocation).filter_by(
        id=assignment.location_id
    ).first()
    location_label = location.name if location else assignment.location_id

    _record_assignment_history(
        db,
        unit_id=assignment.unit_id,
        change_type="assignment_deleted",
        old_value=f"{location_label} ({assignment.assigned_at:%Y-%m-%d} → "
                  f"{assignment.assigned_until and assignment.assigned_until.strftime('%Y-%m-%d') or 'active'})",
        new_value="deleted",
        notes=(
            "Assignment row removed — created in error or accidental duplicate."
        ),
    )

    db.delete(assignment)
    db.commit()

    return {
        "success": True,
        "message": "Assignment deleted.",
    }


@router.post("/assignments/merge")
async def merge_assignments(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Merge multiple consecutive UnitAssignment rows for the same (unit, location)
    into a single record spanning their combined window.

    Use case: a unit's deployment timeline shows 3 stacked rows for the
    same location because the assignment was closed-and-reopened (e.g. via
    location remove + restore) or because metadata-backfill auto-created
    a retroactive window adjacent to a manual one.  Operator sees three
    rows but they represent one continuous deployment.

    Body JSON:
        { "assignment_ids": ["<uuid>", "<uuid>", ...] }

    Validation:
      - All assignments must belong to project_id
      - All must share the same unit_id AND location_id
      - At least 2 ids must be provided

    Merge rules:
      - Keeps the EARLIEST-starting assignment as the surviving row
      - assigned_at  = min(assigned_at across all)
      - assigned_until = max(assigned_until), or NULL if any input was active
      - status = "active" if any input was active, else "completed"
      - source = source of the earliest record (preserves original ingest provenance)
      - notes = earliest's notes + "Merged N records (<sources>)"
      - Other records are DELETED
      - One UnitHistory `assignment_merged` row is written for audit

    No tolerance check — the operator is asking for the merge, so we trust
    the intent.  The UI can pre-filter to only offer "consecutive" merges
    if it wants.
    """
    try:
        payload = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid JSON body")

    ids = payload.get("assignment_ids") or []
    if not isinstance(ids, list) or len(ids) < 2:
        raise HTTPException(
            status_code=400,
            detail="Need at least 2 assignment_ids to merge.",
        )

    assignments = (
        db.query(UnitAssignment)
        .filter(UnitAssignment.project_id == project_id)
        .filter(UnitAssignment.id.in_(ids))
        .all()
    )

    if len(assignments) != len(set(ids)):
        raise HTTPException(
            status_code=404,
            detail=f"Some assignments not found (got {len(assignments)} of {len(set(ids))}).",
        )

    unit_ids = {a.unit_id for a in assignments}
    loc_ids  = {a.location_id for a in assignments}
    if len(unit_ids) > 1 or len(loc_ids) > 1:
        raise HTTPException(
            status_code=400,
            detail="Can only merge assignments that share the same unit and location.",
        )

    # Order chronologically.
    assignments.sort(key=lambda a: a.assigned_at)
    earliest = assignments[0]
    others   = assignments[1:]

    # Compute merged window.
    any_active = any(a.assigned_until is None for a in assignments)
    if any_active:
        merged_until = None
    else:
        merged_until = max(a.assigned_until for a in assignments)

    # Build a brief audit-style note describing what got merged.
    bits = []
    for a in assignments:
        win = (
            f"{a.assigned_at:%Y-%m-%d}"
            f"→{(a.assigned_until and a.assigned_until.strftime('%Y-%m-%d')) or 'active'}"
        )
        bits.append(f"{win} [{a.source}]")
    merge_note_suffix = f"Merged {len(assignments)} records: " + " + ".join(bits)
    new_notes = (earliest.notes + "  •  " + merge_note_suffix) if earliest.notes else merge_note_suffix

    # Resolve names for the audit log before mutating.
    location = db.query(MonitoringLocation).filter_by(id=earliest.location_id).first()
    location_label = location.name if location else earliest.location_id

    # Mutate the survivor.
    earliest.assigned_at    = min(a.assigned_at for a in assignments)
    earliest.assigned_until = merged_until
    earliest.status         = "active" if any_active else "completed"
    earliest.notes          = new_notes

    # Delete the rest.
    deleted_ids = [a.id for a in others]
    for a in others:
        db.delete(a)

    _record_assignment_history(
        db,
        unit_id=earliest.unit_id,
        change_type="assignment_merged",
        old_value=f"{len(assignments)} rows at {location_label}",
        new_value=(
            f"1 row {earliest.assigned_at:%Y-%m-%d}"
            f"→{(merged_until and merged_until.strftime('%Y-%m-%d')) or 'active'}"
        ),
        notes=merge_note_suffix,
    )

    db.commit()
    db.refresh(earliest)

    return {
        "success":         True,
        "message":         f"Merged {len(assignments)} assignments into one.",
        "kept_id":         earliest.id,
        "deleted_ids":     deleted_ids,
        "merged_window": {
            "assigned_at":    earliest.assigned_at.isoformat(),
            "assigned_until": earliest.assigned_until.isoformat() if earliest.assigned_until else None,
        },
    }


@router.post("/locations/{location_id}/swap")
async def swap_unit_on_location(
    project_id: str,
    location_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Swap the unit assigned to a vibration monitoring location.
    Ends the current active assignment (if any), creates a new one,
    and optionally updates modem pairing on the seismograph.
    Works for first-time assignments too (no current assignment = just create).
    """
    location = db.query(MonitoringLocation).filter_by(
        id=location_id,
        project_id=project_id,
    ).first()
    if not location:
        raise HTTPException(status_code=404, detail="Location not found")

    form_data = await request.form()
    unit_id = form_data.get("unit_id")
    modem_id = form_data.get("modem_id") or None
    notes = form_data.get("notes") or None

    if not unit_id:
        raise HTTPException(status_code=400, detail="unit_id is required")

    # Validate new unit
    unit = db.query(RosterUnit).filter_by(id=unit_id).first()
    if not unit:
        raise HTTPException(status_code=404, detail="Unit not found")

    expected_device_type = "slm" if location.location_type == "sound" else "seismograph"
    if unit.device_type != expected_device_type:
        raise HTTPException(
            status_code=400,
            detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
        )

    # End current active assignment if one exists (active = assigned_until IS NULL)
    current = db.query(UnitAssignment).filter(
        and_(
            UnitAssignment.location_id == location_id,
            UnitAssignment.assigned_until == None,
        )
    ).first()
    if current:
        current.assigned_until = datetime.utcnow()
        current.status = "completed"
        # If the swap is replacing a different unit, that unit's deployment ended.
        if current.unit_id != unit_id:
            _record_assignment_history(
                db,
                unit_id=current.unit_id,
                change_type="assignment_swapped",
                old_value=location.name,
                new_value=f"swapped out → {unit_id}",
                notes=notes,
            )
            # Clear the outgoing unit's modem pairing so the bidirectional
            # deployed_with_modem_id / deployed_with_unit_id back-reference
            # doesn't orphan onto the unit that just left the field.
            old_unit = db.query(RosterUnit).filter_by(id=current.unit_id).first()
            if old_unit:
                if old_unit.deployed_with_modem_id:
                    old_modem = db.query(RosterUnit).filter_by(
                        id=old_unit.deployed_with_modem_id, device_type="modem"
                    ).first()
                    if old_modem and old_modem.deployed_with_unit_id == current.unit_id:
                        old_modem.deployed_with_unit_id = None
                    old_unit.deployed_with_modem_id = None
                # Bench the outgoing unit — it's no longer in the field, so
                # the heartbeat / polling subsystem should stop chasing it.
                if old_unit.deployed:
                    old_unit.deployed = False

    # Create new assignment
    new_assignment = UnitAssignment(
        id=str(uuid.uuid4()),
        unit_id=unit_id,
        location_id=location_id,
        project_id=project_id,
        device_type=unit.device_type,
        assigned_until=None,
        status="active",
        notes=notes,
    )
    db.add(new_assignment)
    _record_assignment_history(
        db,
        unit_id=unit_id,
        change_type="assignment_swapped" if (current and current.unit_id != unit_id) else "assignment_created",
        new_value=f"{location.name} (project: {location.project_id})",
        notes=notes,
    )

    # Update modem pairing on the seismograph if modem provided
    if modem_id:
        modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
        if not modem:
            raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
        # Symmetric cleanup: if this modem still claims a previous partner
        # (a different seismograph whose deployed_with_modem_id never got
        # cleared in a past swap), break that stale link before re-pairing.
        if modem.deployed_with_unit_id and modem.deployed_with_unit_id != unit_id:
            prev_partner = db.query(RosterUnit).filter_by(id=modem.deployed_with_unit_id).first()
            if prev_partner and prev_partner.deployed_with_modem_id == modem_id:
                prev_partner.deployed_with_modem_id = None
        unit.deployed_with_modem_id = modem_id
        modem.deployed_with_unit_id = unit_id
        # If the modem was on the bench, swapping it into the field puts it
        # back in rotation.
        if not modem.deployed:
            modem.deployed = True
    else:
        # Clear modem pairing if not provided
        unit.deployed_with_modem_id = None

    # If the incoming unit was benched, putting it in the field flips it
    # back to deployed (so polling / dashboards see it as in rotation).
    if not unit.deployed:
        unit.deployed = True

    db.commit()

    return JSONResponse({
        "success": True,
        "assignment_id": new_assignment.id,
        "message": f"Unit '{unit_id}' assigned to '{location.name}'" + (f" with modem '{modem_id}'" if modem_id else ""),
    })


# ============================================================================
# Available Units for Assignment
# ============================================================================

@router.get("/available-modems", response_class=JSONResponse)
async def get_available_modems(
    project_id: str,
    db: Session = Depends(get_db),
    include_benched: bool = Query(False),
):
    """
    Get all non-retired modems for the modem assignment dropdown.

    By default only deployed (in-rotation) modems are returned, preserving
    the existing behavior for callers like the location-detail swap modal.
    Pass ``include_benched=true`` to also include benched modems
    (``RosterUnit.deployed == False``) — useful when picking a modem to
    pull off the bench for a field swap.  Each row's ``deployed`` flag is
    returned so the UI can badge benched candidates.
    """
    filters = [
        RosterUnit.device_type == "modem",
        RosterUnit.retired == False,
    ]
    if not include_benched:
        filters.append(RosterUnit.deployed == True)
    modems = db.query(RosterUnit).filter(and_(*filters)).order_by(RosterUnit.id).all()

    return [
        {
            "id": m.id,
            "hardware_model": m.hardware_model,
            "ip_address": m.ip_address,
            "deployed": bool(m.deployed),
        }
        for m in modems
    ]


@router.get("/available-units", response_class=JSONResponse)
async def get_available_units(
    project_id: str,
    location_type: str = Query(...),
    db: Session = Depends(get_db),
    include_benched: bool = Query(False),
):
    """
    Get list of available units for assignment to a location.
    Filters by device type matching the location type.

    By default only deployed (in-rotation) units are returned, preserving
    the existing location-detail swap-modal behavior.  Pass
    ``include_benched=true`` to also include benched units
    (``RosterUnit.deployed == False``) — exactly the candidates you'd
    pull off the bench for a field swap.  Each row carries a ``deployed``
    flag so the UI can badge benched picks.
    """
    # Determine required device type
    required_device_type = "slm" if location_type == "sound" else "seismograph"

    # Get all units of the required type that aren't retired (and optionally
    # exclude benched units).
    filters = [
        RosterUnit.device_type == required_device_type,
        RosterUnit.retired == False,
    ]
    if not include_benched:
        filters.append(RosterUnit.deployed == True)
    all_units = db.query(RosterUnit).filter(and_(*filters)).all()

    # Filter out units that already have active assignments (active = assigned_until IS NULL)
    assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
        UnitAssignment.assigned_until == None
    ).distinct().all()
    assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]

    # These units have no active assignment by definition, so there's no
    # current location to show — leave the field empty.
    available_units = [
        {
            "id": unit.id,
            "device_type": unit.device_type,
            "location": "",
            "model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
            "deployed": bool(unit.deployed),
        }
        for unit in all_units
        if unit.id not in assigned_unit_ids
    ]

    return available_units


# ============================================================================
# NRL-specific endpoints for detail page
# ============================================================================

@router.get("/nrl/{location_id}/sessions", response_class=HTMLResponse)
async def get_nrl_sessions(
    project_id: str,
    location_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Get monitoring sessions for a specific NRL.
    Returns HTML partial with session list.
    """
    sessions = db.query(MonitoringSession).filter_by(
        location_id=location_id
    ).order_by(MonitoringSession.started_at.desc()).all()

    # Enrich with unit details
    sessions_data = []
    for session in sessions:
        unit = None
        if session.unit_id:
            unit = db.query(RosterUnit).filter_by(id=session.unit_id).first()

        sessions_data.append({
            "session": session,
            "unit": unit,
        })

    return templates.TemplateResponse("partials/projects/session_list.html", {
        "request": request,
        "project_id": project_id,
        "location_id": location_id,
        "sessions": sessions_data,
    })


@router.get("/vibration_summary", response_class=HTMLResponse)
async def get_project_vibration_summary(
    project_id: str,
    request: Request,
    from_dt: Optional[datetime] = Query(None),
    to_dt: Optional[datetime] = Query(None),
    db: Session = Depends(get_db),
):
    """
    Render a small HTML partial summarising vibration-event activity
    across every vibration MonitoringLocation in the project.

    Returned to the Vibration tab of the project detail page via HTMX.
    Fans out concurrently across all locations (which in turn fan out
    across each location's UnitAssignment windows).  Total queries to
    SFM = sum of assignments across the project.

    404 if the project doesn't exist.  Empty-state partial if the
    project has no vibration locations.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found.")

    from backend.services.sfm_events import vibration_summary_for_project

    summary = await vibration_summary_for_project(
        db, project_id, from_dt=from_dt, to_dt=to_dt
    )

    return templates.TemplateResponse(
        "partials/projects/vibration_summary.html",
        {
            "request":    request,
            "project_id": project_id,
            "summary":    summary,
        },
    )


@router.get("/locations/{location_id}/events", response_class=JSONResponse)
async def get_location_events(
    project_id: str,
    location_id: str,
    from_dt: Optional[datetime] = Query(None),
    to_dt: Optional[datetime] = Query(None),
    false_trigger: Optional[bool] = Query(None),
    limit: int = Query(500, ge=1, le=5000),
    db: Session = Depends(get_db),
):
    """
    Return SFM events recorded at this monitoring location.

    Fans out the location's UnitAssignment rows (every seismograph ever
    assigned to this location, active + closed), queries SFM /db/events
    for each (serial, time-window) pair concurrently, and unions the
    results.

    Sound (SLM) locations return an empty payload — SFM events are
    seismograph-only.
    """
    location = db.query(MonitoringLocation).filter_by(id=location_id).first()
    if not location:
        raise HTTPException(status_code=404, detail="Location not found.")
    if location.project_id != project_id:
        raise HTTPException(
            status_code=404,
            detail="Location does not belong to this project.",
        )

    # SLM locations don't have SFM events — return an empty payload rather
    # than 404 so the frontend can render an empty state gracefully.
    if location.location_type != "vibration":
        return {
            "events":           [],
            "count":             0,
            "stats": {
                "event_count":         0,
                "peak_pvs":            None,
                "peak_pvs_at":         None,
                "peak_pvs_serial":     None,
                "last_event":          None,
                "false_trigger_count": 0,
            },
            "assignments_used": [],
            "location_type":    location.location_type,
        }

    from backend.services.sfm_events import events_for_location

    result = await events_for_location(
        db,
        location_id,
        from_dt=from_dt,
        to_dt=to_dt,
        false_trigger=false_trigger,
        limit=limit,
    )
    result["location_type"] = location.location_type
    return result


@router.get("/nrl/{location_id}/files", response_class=HTMLResponse)
async def get_nrl_files(
    project_id: str,
    location_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Get data files for a specific NRL.
    Returns HTML partial with file list.
    """
    # Join DataFile with MonitoringSession to filter by location_id
    files = db.query(DataFile).join(
        MonitoringSession,
        DataFile.session_id == MonitoringSession.id
    ).filter(
        MonitoringSession.location_id == location_id
    ).order_by(DataFile.created_at.desc()).all()

    # Enrich with session details
    files_data = []
    for file in files:
        session = None
        if file.session_id:
            session = db.query(MonitoringSession).filter_by(id=file.session_id).first()

        files_data.append({
            "file": file,
            "session": session,
        })

    return templates.TemplateResponse("partials/projects/file_list.html", {
        "request": request,
        "project_id": project_id,
        "location_id": location_id,
        "files": files_data,
    })


# ============================================================================
# Manual SD Card Data Upload
# ============================================================================

def _parse_rnh(content: bytes) -> dict:
    """
    Parse a Rion .rnh metadata file (INI-style with [Section] headers).
    Returns a dict of key metadata fields.
    """
    result = {}
    try:
        text = content.decode("utf-8", errors="replace")
        for line in text.splitlines():
            line = line.strip()
            if not line or line.startswith("["):
                continue
            if "," in line:
                key, _, value = line.partition(",")
                key = key.strip()
                value = value.strip()
                if key == "Serial Number":
                    result["serial_number"] = value
                elif key == "Store Name":
                    result["store_name"] = value
                elif key == "Index Number":
                    result["index_number"] = value
                elif key == "Measurement Start Time":
                    result["start_time_str"] = value
                elif key == "Measurement Stop Time":
                    result["stop_time_str"] = value
                elif key == "Total Measurement Time":
                    result["total_time_str"] = value
                elif key == "Frequency Weighting (Main)":
                    result["frequency_weighting"] = value
                elif key == "Time Weighting (Main)":
                    result["time_weighting"] = value
                elif key == "Leq Calculation Interval":
                    result["leq_interval"] = value
                elif key.startswith("Percentile "):
                    # e.g. "Percentile 4,90.0" → percentiles["4"] = "90.0".
                    # Lets the report label the LN slots (here LN4 = L90) from the
                    # device's own config instead of hardcoding which slot is which —
                    # the percentile assignment is reconfigurable per job.
                    slot = key[len("Percentile "):].strip()
                    result.setdefault("percentiles", {})[slot] = value
    except Exception:
        pass
    return result


def _parse_rnh_datetime(s: str):
    """Parse RNH datetime string: '2026/02/17 19:00:19' -> datetime"""
    from datetime import datetime
    if not s:
        return None
    try:
        return datetime.strptime(s.strip(), "%Y/%m/%d %H:%M:%S")
    except Exception:
        return None


def _classify_file(filename: str) -> str:
    """Classify a file by name into a DataFile file_type."""
    name = filename.lower()
    if name.endswith(".rnh"):
        return "log"
    if name.endswith(".rnd"):
        return "measurement"
    if name.endswith(".zip"):
        return "archive"
    return "data"


def _rnd_interval_seconds(s: Optional[str]) -> Optional[int]:
    """Parse an NL-43 interval string ('15m' / '1s' / '1h') into seconds."""
    import re
    m = re.match(r"\s*(\d+)\s*([smh])", (s or "").strip().lower())
    if not m:
        return None
    return int(m.group(1)) * {"s": 1, "m": 60, "h": 3600}[m.group(2)]


def _leq_window_local(leq_bytes: bytes):
    """Recording window from a Leq .rnd's 'Start Time' column (meter-local time).

    Returns (first_start, last_start, row_count, inferred_interval_seconds).
    This is the source of truth for the recording window on NL-43 units, whose
    .rnh carries no measurement timestamps.  Reuses the report's AU2 normaliser
    so NL-43 and AU2 files parse identically.
    """
    import csv as _csv
    from backend.routers.projects import _normalize_rnd_rows  # lazy: avoid import cycle
    try:
        text = leq_bytes.decode("utf-8", errors="replace")
        rows = list(_csv.DictReader(io.StringIO(text)))
    except Exception:
        return None, None, 0, None
    try:
        rows, _ = _normalize_rnd_rows(rows)
    except Exception:
        pass
    times = []
    for r in rows:
        v = (r.get("Start Time") or "").strip()
        try:
            times.append(datetime.strptime(v, "%Y/%m/%d %H:%M:%S"))
        except (ValueError, TypeError):
            continue
    if not times:
        return None, None, 0, None
    times.sort()
    inferred = int((times[1] - times[0]).total_seconds()) if len(times) >= 2 else None
    return times[0], times[-1], len(times), inferred


def _is_wanted_nrl_file(fname: str) -> bool:
    """Keep only the files an NRL ingest cares about: .rnh metadata + the
    averaged Leq .rnd.  Drops the 1-second _Lp_ files and everything else.

      - NL-43 writes two .rnd types: _Leq_ (15-min averages, wanted) and
        _Lp_ (1-second granular, skipped).
      - AU2 (NL-23/older Rion) writes a single Au2_####.rnd — always keep.

    Note this is purely about which *files* to store, not which *metrics* to
    report: the kept Leq file carries every column (Leq, Lmax, L1/L10/L50/
    L90/L95, Lpeak, …), so the report layer can select any metric later.
    """
    n = fname.lower()
    if n.endswith(".rnh"):
        return True
    if n.endswith(".rnd"):
        if "_leq_" in n:          # NL-43 Leq file
            return True
        if n.startswith("au2_"):  # AU2 format (NL-23) — Leq equivalent
            return True
        if "_lp" not in n and "_leq_" not in n:
            # Unknown .rnd format — include it so we don't silently drop data
            return True
    return False


class IngestError(Exception):
    """Raised when an NRL upload/ZIP has no usable data or an invalid target.

    Kept HTTP-agnostic so the ingest core can be driven programmatically (the
    scheduled FTP pull) as well as from the HTTP upload endpoint.  Callers
    translate it: the endpoint → HTTP 400, the scheduler → logged failure.
    """
    pass


def _find_existing_session(
    db: Session,
    location_id: str,
    store_name: str,
    started_at,
    start_time_str: str,
):
    """Return an already-ingested session for this location that represents the
    same measurement, or None.

    Used to make FTP re-pulls idempotent: a daily cycle closes one Auto_####
    folder per day, so a session is uniquely identified within a location by
    (store_name + measurement start time).  Store names recycle across jobs, so
    we always match on start time too.
    """
    if not store_name and not started_at:
        return None
    candidates = db.query(MonitoringSession).filter(
        MonitoringSession.location_id == location_id,
        MonitoringSession.session_type == "sound",
    ).all()
    for s in candidates:
        try:
            meta = json.loads(s.session_metadata or "{}")
        except (json.JSONDecodeError, TypeError):
            meta = {}
        if store_name and meta.get("store_name") != store_name:
            continue
        # Same store_name — confirm it's the same measurement by start time.
        if start_time_str and meta.get("start_time_str") == start_time_str:
            return s
        if not meta.get("start_time_str") and started_at and s.started_at == started_at:
            return s
    return None


def _ingest_file_entries(
    location: MonitoringLocation,
    file_entries: list[tuple[str, bytes]],
    db: Session,
    *,
    source: str = "manual_upload",
    dedupe: bool = False,
    unit_id: Optional[str] = None,
) -> dict:
    """Core NRL ingest, shared by the HTTP upload and the programmatic FTP pull.

    Takes already-normalized (filename, bytes) entries, keeps the wanted files,
    parses the .rnh, and creates a MonitoringSession + DataFile rows under the
    location's project.  Metric-agnostic: the full Leq file is written to disk
    and every column preserved; metric selection happens in the report layer.

    `unit_id` attributes the session to the recording unit when the caller knows
    it (manual FTP download / SD upload from a known unit).  Left None for paths
    that link the unit afterwards (the scheduler's `_ingest_and_link`).

    Raises IngestError if no usable files are present.
    """
    # --- Filter to the files we keep (.rnh + Leq .rnd) ---
    file_entries = [(f, b) for f, b in file_entries if _is_wanted_nrl_file(f)]
    if not file_entries:
        raise IngestError(
            "No usable .rnd or .rnh files found. Expected NL-43 _Leq_ files or AU2 format .rnd files."
        )

    # --- Parse .rnh metadata (first one wins) ---
    rnh_meta = {}
    for fname, fbytes in file_entries:
        if fname.lower().endswith(".rnh"):
            rnh_meta = _parse_rnh(fbytes)
            break

    # RNH stores local time (no UTC offset).  Use local for period/label, then
    # convert to UTC for storage so the local_datetime filter displays correctly.
    started_at_local = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
    stopped_at_local = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
    started_at = local_to_utc(started_at_local)
    stopped_at = local_to_utc(stopped_at_local) if stopped_at_local else None
    duration_seconds = (
        int((stopped_at - started_at).total_seconds())
        if (started_at and stopped_at) else None
    )

    store_name = rnh_meta.get("store_name", "")
    serial_number = rnh_meta.get("serial_number", "")
    index_number = rnh_meta.get("index_number", "")
    start_time_str = rnh_meta.get("start_time_str", "")

    # The NL-43 .rnh has NO measurement timestamps — the real recording window
    # lives in the Leq .rnd's "Start Time" column.  Whenever the header didn't
    # give us a start (and/or stop), derive it from the Leq rows so the session
    # gets the true window + duration (and a stable start_time_str for dedupe).
    if not start_time_str or stopped_at_local is None:
        leq_entry = next(
            ((f, b) for f, b in file_entries
             if f.lower().endswith(".rnd") and ("_leq_" in f.lower() or f.lower().startswith("au2_"))),
            None,
        )
        if leq_entry is not None:
            first_dt, last_dt, _n, inferred = _leq_window_local(leq_entry[1])
            interval_s = _rnd_interval_seconds(rnh_meta.get("leq_interval")) or inferred or 0
            if first_dt and not start_time_str:
                started_at_local = first_dt
                start_time_str = first_dt.strftime("%Y/%m/%d %H:%M:%S")
            if last_dt and stopped_at_local is None:
                stopped_at_local = last_dt + timedelta(seconds=interval_s)
            # Recompute UTC + duration from the resolved window.
            started_at = local_to_utc(started_at_local)
            stopped_at = local_to_utc(stopped_at_local) if stopped_at_local else None
            duration_seconds = (
                int((stopped_at - started_at).total_seconds())
                if (started_at and stopped_at) else duration_seconds
            )

    # --- Dedupe: skip if this exact measurement is already ingested ---
    if dedupe:
        existing = _find_existing_session(db, location.id, store_name, started_at, start_time_str)
        if existing:
            return {
                "success": True,
                "deduped": True,
                "session_id": existing.id,
                "files_imported": 0,
                "leq_files": 0,
                "lp_files": 0,
                "metadata_files": 0,
                "store_name": store_name,
                "started_at": started_at.isoformat() if started_at else None,
                "stopped_at": stopped_at.isoformat() if stopped_at else None,
                "duration_seconds": duration_seconds,
            }

    # --- Create MonitoringSession (local times drive period/label) ---
    period_type = _derive_period_type(started_at_local) if started_at_local else None
    session_label = (
        _build_session_label(started_at_local, location.name, period_type)
        if started_at_local else None
    )

    session_id = str(uuid.uuid4())
    monitoring_session = MonitoringSession(
        id=session_id,
        project_id=location.project_id,
        location_id=location.id,
        unit_id=unit_id,
        session_type="sound",
        started_at=started_at,
        stopped_at=stopped_at,
        duration_seconds=duration_seconds,
        status="completed",
        session_label=session_label,
        period_type=period_type,
        session_metadata=json.dumps({
            "source": source,
            "store_name": store_name,
            "serial_number": serial_number,
            "index_number": index_number,
            "start_time_str": start_time_str,
            # Captured from the .rnh so the report can label metrics from the
            # device's own config (which LN slot is L90, the weightings, etc.).
            "percentiles": rnh_meta.get("percentiles", {}),
            "frequency_weighting": rnh_meta.get("frequency_weighting", ""),
            "time_weighting": rnh_meta.get("time_weighting", ""),
            "leq_interval": rnh_meta.get("leq_interval", ""),
        }),
    )
    db.add(monitoring_session)
    db.commit()
    db.refresh(monitoring_session)

    # --- Write files to disk + create DataFile records ---
    output_dir = Path("data/Projects") / location.project_id / session_id
    output_dir.mkdir(parents=True, exist_ok=True)

    leq_count = lp_count = metadata_count = files_imported = 0
    for fname, fbytes in file_entries:
        fname_lower = fname.lower()
        if fname_lower.endswith(".rnd"):
            if "_leq_" in fname_lower:
                leq_count += 1
            elif "_lp" in fname_lower:
                lp_count += 1
        elif fname_lower.endswith(".rnh"):
            metadata_count += 1

        dest = output_dir / fname
        dest.write_bytes(fbytes)
        checksum = hashlib.sha256(fbytes).hexdigest()
        rel_path = str(dest.relative_to("data"))

        db.add(DataFile(
            id=str(uuid.uuid4()),
            session_id=session_id,
            file_path=rel_path,
            file_type=_classify_file(fname),
            file_size_bytes=len(fbytes),
            downloaded_at=datetime.utcnow(),
            checksum=checksum,
            file_metadata=json.dumps({
                "source": source,
                "original_filename": fname,
                "store_name": store_name,
            }),
        ))
        files_imported += 1

    db.commit()

    return {
        "success": True,
        "deduped": False,
        "session_id": session_id,
        "files_imported": files_imported,
        "leq_files": leq_count,
        "lp_files": lp_count,
        "metadata_files": metadata_count,
        "store_name": store_name,
        "started_at": started_at.isoformat() if started_at else None,
        "stopped_at": stopped_at.isoformat() if stopped_at else None,
        "duration_seconds": duration_seconds,
    }


def ingest_nrl_zip(
    location_id: str,
    zip_bytes: bytes,
    db: Session,
    *,
    source: str = "ftp_pull",
    dedupe: bool = True,
    unit_id: Optional[str] = None,
) -> dict:
    """Programmatically ingest an Auto_#### ZIP (e.g. a scheduled FTP pull).

    Extracts the ZIP (flattening any nested Auto_Leq/Auto_Lp_ folders), keeps
    the .rnh + Leq .rnd, parses the header, and creates a MonitoringSession +
    DataFile rows for `location_id`.  Defaults to dedupe=True so repeated daily
    pulls of the same closed folder don't create duplicate sessions.  Pass
    `unit_id` to attribute the session to the recording unit at creation.

    Returns the same dict shape as the HTTP upload, plus a `deduped` flag.
    Raises IngestError on a bad ZIP, no usable files, or unknown location.
    """
    location = db.query(MonitoringLocation).filter_by(id=location_id).first()
    if not location:
        raise IngestError(f"Location {location_id} not found")

    try:
        with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
            file_entries: list[tuple[str, bytes]] = []
            for info in zf.infolist():
                if info.is_dir():
                    continue
                name = Path(info.filename).name  # strip nested folder paths
                if not name:
                    continue
                file_entries.append((name, zf.read(info)))
    except zipfile.BadZipFile:
        raise IngestError("Downloaded data is not a valid ZIP archive.")

    return _ingest_file_entries(location, file_entries, db, source=source, dedupe=dedupe, unit_id=unit_id)


@router.post("/nrl/{location_id}/upload-data")
async def upload_nrl_data(
    project_id: str,
    location_id: str,
    db: Session = Depends(get_db),
    files: list[UploadFile] = File(...),
):
    """
    Manually upload SD card data for an offline NRL.

    Accepts either:
    - A single .zip file (the Auto_#### folder zipped) — auto-extracted
    - Multiple .rnd / .rnh files selected directly from the SD card folder

    Normalizes the upload to (filename, bytes) entries, then hands off to the
    shared ingest core (`_ingest_file_entries`) — the same path the scheduled
    FTP pull uses via `ingest_nrl_zip`.  Creates a MonitoringSession from the
    .rnh metadata and DataFile records for each measurement file.  No unit
    assignment required.  dedupe=False here preserves the prior manual-upload
    behaviour (re-uploading creates a fresh session).
    """
    # Verify project and location exist
    project = db.query(Project).filter_by(id=project_id).first()
    _require_module(project, "sound_monitoring", db)

    location = db.query(MonitoringLocation).filter_by(
        id=location_id, project_id=project_id
    ).first()
    if not location:
        raise HTTPException(status_code=404, detail="Location not found")

    # --- Normalize upload to (filename, bytes) entries ---
    file_entries: list[tuple[str, bytes]] = []

    if len(files) == 1 and files[0].filename.lower().endswith(".zip"):
        raw = await files[0].read()
        try:
            with zipfile.ZipFile(io.BytesIO(raw)) as zf:
                for info in zf.infolist():
                    if info.is_dir():
                        continue
                    name = Path(info.filename).name  # strip folder path
                    if not name:
                        continue
                    file_entries.append((name, zf.read(info)))
        except zipfile.BadZipFile:
            raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive.")
    else:
        for uf in files:
            data = await uf.read()
            file_entries.append((uf.filename, data))

    if not file_entries:
        raise HTTPException(status_code=400, detail="No usable files found in upload.")

    # --- Hand off to the shared ingest core ---
    try:
        return _ingest_file_entries(location, file_entries, db, source="manual_upload", dedupe=False)
    except IngestError as e:
        raise HTTPException(status_code=400, detail=str(e))


# ============================================================================
# NRL Live Status (connected NRLs only)
# ============================================================================

@router.get("/nrl/{location_id}/live-status", response_class=HTMLResponse)
async def get_nrl_live_status(
    project_id: str,
    location_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Fetch cached status from SLMM for the unit assigned to this NRL and
    return a compact HTML status card. Used in the NRL overview tab for
    connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
    Sound Monitoring projects only.
    """
    import os
    import httpx

    _require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)

    # Find the assigned unit (active = assigned_until IS NULL)
    assignment = db.query(UnitAssignment).filter(
        and_(
            UnitAssignment.location_id == location_id,
            UnitAssignment.assigned_until == None,
        )
    ).first()

    if not assignment:
        return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
            "request": request,
            "status": None,
            "error": "No unit assigned",
        })

    unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
    if not unit:
        return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
            "request": request,
            "status": None,
            "error": "Assigned unit not found",
        })

    slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
    status_data = None
    error_msg = None

    try:
        async with httpx.AsyncClient(timeout=5.0) as client:
            resp = await client.get(f"{slmm_base}/api/nl43/{unit.id}/status")
            if resp.status_code == 200:
                status_data = resp.json()
            else:
                error_msg = f"SLMM returned {resp.status_code}"
    except Exception as e:
        error_msg = "SLMM unreachable"

    return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
        "request": request,
        "unit": unit,
        "status": status_data,
        "error": error_msg,
    })
