"""
Projects Router

Provides API endpoints for the Projects system:
- Project CRUD operations
- Project dashboards
- Project statistics
- Type-aware features
"""

from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_
from datetime import datetime, timedelta
from typing import Optional
from collections import OrderedDict
import uuid
import json
import logging
import io

from backend.utils.timezone import utc_to_local, format_local_datetime, local_to_utc

from backend.database import get_db
from fastapi import UploadFile, File
import zipfile
import hashlib
import pathlib as _pathlib

from backend.models import (
    Project,
    ProjectType,
    ProjectModule,
    MonitoringLocation,
    UnitAssignment,
    MonitoringSession,
    DataFile,
    ScheduledAction,
    RecurringSchedule,
    RosterUnit,
)
from backend.templates_config import templates

router = APIRouter(prefix="/api/projects", tags=["projects"])
logger = logging.getLogger(__name__)


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

# Registry of known module types. Add new entries here to make them available
# in the UI for any project.
MODULES = {
    "sound_monitoring":     {"name": "Sound Monitoring",     "icon": "speaker",   "color": "orange"},
    "vibration_monitoring": {"name": "Vibration Monitoring", "icon": "activity",  "color": "blue"},
}


def _get_project_modules(project_id: str, db: Session) -> list[str]:
    """Return list of enabled module_type strings for a project."""
    rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
    return [r.module_type for r in rows]


def _require_module(project: 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:
        module_name = MODULES.get(module_type, {}).get("name", module_type)
        raise HTTPException(
            status_code=400,
            detail=f"This project does not have the {module_name} module enabled.",
        )


# Keep legacy alias so any call sites not yet migrated still work
def _require_sound_project(project: Project, db: Session = None) -> None:
    if db is not None:
        _require_module(project, "sound_monitoring", db)
    elif not project or project.project_type_id != "sound_monitoring":
        raise HTTPException(
            status_code=400,
            detail="This feature is only available for Sound Monitoring projects.",
        )


# ============================================================================
# RND file normalization — maps AU2 (older Rion) column names to the NL-43
# equivalents so report generation and the web viewer work for both formats.
# AU2 files: LAeq, LAmax, LAmin, LA01, LA10, LA50, LA90, LA95, LCpeak
# NL-43 files: Leq(Main), Lmax(Main), Lmin(Main), LN1(Main) … Lpeak(Main)
# ============================================================================

_AU2_TO_NL43 = {
    "LAeq":   "Leq(Main)",
    "LAmax":  "Lmax(Main)",
    "LAmin":  "Lmin(Main)",
    "LCpeak": "Lpeak(Main)",
    "LA01":   "LN1(Main)",
    "LA10":   "LN2(Main)",
    "LA50":   "LN3(Main)",
    "LA90":   "LN4(Main)",
    "LA95":   "LN5(Main)",
    # Time column differs too
    "Time":   "Start Time",
}


def _normalize_rnd_rows(rows: list[dict]) -> tuple[list[dict], bool]:
    """
    Detect AU2-format RND rows (by presence of 'LAeq' key) and remap column
    names to NL-43 equivalents.  Returns (normalized_rows, was_au2_format).
    If already NL-43 format the rows are returned unchanged.
    """
    if not rows:
        return rows, False
    if "LAeq" not in rows[0]:
        return rows, False  # already NL-43 format

    normalized = []
    for row in rows:
        new_row = {}
        for k, v in row.items():
            new_row[_AU2_TO_NL43.get(k, k)] = v
        normalized.append(new_row)
    return normalized, True


def _peek_rnd_headers(file_path) -> list[dict]:
    """Read just the first data row of an RND file to check column names cheaply."""
    import csv as _csv
    try:
        with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
            reader = _csv.DictReader(f)
            row = next(reader, None)
            return [row] if row else []
    except Exception:
        return []


def _is_leq_file(file_path: str, rows: list[dict]) -> bool:
    """
    Return True if this RND file contains Leq (15-min averaged) data.
    Accepts NL-43 Leq files (_Leq_ in path) and AU2 files (LAeq column or
    Leq(Main) column after normalisation).
    """
    if "_Leq_" in file_path:
        return True
    if rows and ("LAeq" in rows[0] or "Leq(Main)" in rows[0]):
        return True
    return False


def _filter_rnd_rows(
    rows: list[dict],
    filter_start_time: str,
    filter_end_time: str,
    filter_start_date: str,
    filter_end_date: str,
) -> list[dict]:
    """Filter RND data rows by time window and/or date range. Handles overnight ranges.

    Grace window: intervals starting up to 15 minutes before the filter start time are
    included. This covers the common case where a unit is deployed slightly early
    (e.g. set up at 6:50 for a 7:00 PM start) and the first interval begins just before
    the nominal window. The grace window applies only to the start boundary.
    """
    if not filter_start_time and not filter_end_time and not filter_start_date and not filter_end_date:
        return rows

    _GRACE_MINUTES = 15

    filtered = []

    start_hour = start_minute = end_hour = end_minute = None
    if filter_start_time:
        try:
            parts = filter_start_time.split(':')
            start_hour = int(parts[0])
            start_minute = int(parts[1]) if len(parts) > 1 else 0
        except (ValueError, IndexError):
            pass

    if filter_end_time:
        try:
            parts = filter_end_time.split(':')
            end_hour = int(parts[0])
            end_minute = int(parts[1]) if len(parts) > 1 else 0
        except (ValueError, IndexError):
            pass

    start_dt = end_dt = None
    if filter_start_date:
        try:
            start_dt = datetime.strptime(filter_start_date, '%Y-%m-%d').date()
        except ValueError:
            pass
    if filter_end_date:
        try:
            end_dt = datetime.strptime(filter_end_date, '%Y-%m-%d').date()
        except ValueError:
            pass

    for row in rows:
        start_time_str = row.get('Start Time', '')
        if not start_time_str:
            continue

        try:
            dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
            row_date = dt.date()
            row_hour = dt.hour
            row_minute = dt.minute

            if start_dt and row_date < start_dt:
                continue
            if end_dt and row_date > end_dt:
                continue

            if start_hour is not None and end_hour is not None:
                row_time_minutes = row_hour * 60 + row_minute
                start_time_minutes = start_hour * 60 + start_minute
                end_time_minutes = end_hour * 60 + end_minute
                # Effective start allows up to 15 min early (wraps midnight correctly)
                effective_start = (start_time_minutes - _GRACE_MINUTES) % (24 * 60)

                if start_time_minutes > end_time_minutes:
                    # Overnight range (e.g., 19:00-07:00)
                    # With grace: effective start may be e.g. 18:45
                    if effective_start > end_time_minutes:
                        # Grace window doesn't wrap past midnight itself
                        in_window = (row_time_minutes >= effective_start or row_time_minutes < end_time_minutes)
                    else:
                        # Grace window wraps midnight (rare: start near 00:00)
                        in_window = (row_time_minutes >= effective_start and row_time_minutes < end_time_minutes)
                    if not in_window:
                        continue
                else:
                    # Same-day range (e.g., 07:00-19:00)
                    if not (effective_start <= row_time_minutes < end_time_minutes):
                        continue

            filtered.append(row)
        except ValueError:
            filtered.append(row)

    return filtered


def _read_rnd_file_rows(file_path_str: str) -> list[dict]:
    """Read and parse a single RND CSV file into a list of cleaned row dicts."""
    import csv as _csv
    from pathlib import Path as _Path

    file_path = _Path("data") / file_path_str
    if not file_path.exists():
        return []

    try:
        with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
            content = f.read()
        rows = []
        reader = _csv.DictReader(io.StringIO(content))
        for row in reader:
            cleaned_row = {}
            for key, value in row.items():
                if key:
                    cleaned_key = key.strip()
                    cleaned_value = value.strip() if value else ''
                    if cleaned_value and cleaned_value not in ['-.-', '-', '']:
                        try:
                            cleaned_value = float(cleaned_value)
                        except ValueError:
                            pass
                    elif cleaned_value in ['-.-', '-']:
                        cleaned_value = None
                    cleaned_row[cleaned_key] = cleaned_value
            rows.append(cleaned_row)
        return rows
    except Exception:
        return []


def _build_combined_location_data(
    project_id: str,
    db,
    start_time: str = "",
    end_time: str = "",
    start_date: str = "",
    end_date: str = "",
    enabled_locations: list = None,
) -> dict:
    """
    Read all Leq RND files for a project, apply time/date filters, and return
    per-location spreadsheet data ready for the wizard preview.

    Returns:
        {
            "project": Project,
            "location_data": [
                {
                    "location_name": str,
                    "raw_count": int,
                    "filtered_count": int,
                    "spreadsheet_data": [[idx, date, time, lmax, ln1, ln2, ""], ...]
                },
                ...
            ]
        }
    Raises HTTPException 404 if project not found or no Leq files exist.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()

    # Group Leq files by location
    location_files: dict = {}
    for session in sessions:
        files = db.query(DataFile).filter_by(session_id=session.id).all()
        for file in files:
            if not file.file_path or not file.file_path.lower().endswith('.rnd'):
                continue
            from pathlib import Path as _Path
            abs_path = _Path("data") / file.file_path
            peek = _peek_rnd_headers(abs_path)
            if not _is_leq_file(file.file_path, peek):
                continue
            location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
            loc_name = location.name if location else f"Session {session.id[:8]}"
            if loc_name not in location_files:
                location_files[loc_name] = []
            location_files[loc_name].append(file)

    if not location_files:
        raise HTTPException(status_code=404, detail="No Leq measurement files found in project.")

    # Filter by enabled_locations if specified
    if enabled_locations:
        location_files = {k: v for k, v in location_files.items() if k in enabled_locations}
        if not location_files:
            raise HTTPException(status_code=404, detail="None of the selected locations have Leq files.")

    location_data = []
    for loc_name, files in sorted(location_files.items()):
        all_rows = []
        for file in files:
            rows = _read_rnd_file_rows(file.file_path)
            rows, _ = _normalize_rnd_rows(rows)
            all_rows.extend(rows)

        if not all_rows:
            continue

        all_rows.sort(key=lambda r: r.get('Start Time', ''))
        raw_count = len(all_rows)

        filtered_rows = _filter_rnd_rows(all_rows, start_time, end_time, start_date, end_date)

        spreadsheet_data = []
        for idx, row in enumerate(filtered_rows, 1):
            start_time_str = row.get('Start Time', '')
            date_str = time_str = ''
            if start_time_str:
                try:
                    dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
                    date_str = dt.strftime('%Y-%m-%d')
                    time_str = dt.strftime('%H:%M')
                except ValueError:
                    date_str = start_time_str

            lmax = row.get('Lmax(Main)', '')
            ln1 = row.get('LN1(Main)', '')
            ln2 = row.get('LN2(Main)', '')

            spreadsheet_data.append([
                idx,
                date_str,
                time_str,
                lmax if lmax else '',
                ln1 if ln1 else '',
                ln2 if ln2 else '',
                '',
            ])

        location_data.append({
            "location_name": loc_name,
            "raw_count": raw_count,
            "filtered_count": len(filtered_rows),
            "spreadsheet_data": spreadsheet_data,
        })

    return {"project": project, "location_data": location_data}


# ============================================================================
# Project List & Overview
# ============================================================================

@router.get("/list", response_class=HTMLResponse)
async def get_projects_list(
    request: Request,
    db: Session = Depends(get_db),
    status: Optional[str] = Query(None),
    project_type_id: Optional[str] = Query(None),
    view: Optional[str] = Query(None),
):
    """
    Get list of all projects.
    Returns HTML partial with project cards.
    """
    query = db.query(Project)

    # Filter by status if provided; otherwise exclude archived/deleted from default view
    if status == "all":
        query = query.filter(Project.status != "deleted")
    elif status:
        query = query.filter(Project.status == status)
    else:
        query = query.filter(Project.status.notin_(["deleted", "archived", "completed"]))

    # Filter by project type if provided
    if project_type_id:
        query = query.filter(Project.project_type_id == project_type_id)

    projects = query.order_by(Project.created_at.desc()).all()

    # Enrich each project with stats
    projects_data = []
    for project in projects:
        # Get project type
        project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()

        # Count locations
        location_count = db.query(func.count(MonitoringLocation.id)).filter_by(
            project_id=project.id
        ).scalar()

        # Count assigned units (active = assigned_until IS NULL)
        unit_count = db.query(func.count(UnitAssignment.id)).filter(
            and_(
                UnitAssignment.project_id == project.id,
                UnitAssignment.assigned_until == None,
            )
        ).scalar()

        # Count active sessions
        active_session_count = db.query(func.count(MonitoringSession.id)).filter(
            and_(
                MonitoringSession.project_id == project.id,
                MonitoringSession.status == "recording",
            )
        ).scalar()

        projects_data.append({
            "project": project,
            "project_type": project_type,
            "location_count": location_count,
            "unit_count": unit_count,
            "active_session_count": active_session_count,
        })

    template_name = "partials/projects/project_list.html"
    if view == "compact":
        template_name = "partials/projects/project_list_compact.html"

    return templates.TemplateResponse(template_name, {
        "request": request,
        "projects": projects_data,
    })


@router.get("/stats", response_class=HTMLResponse)
async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
    """
    Get summary statistics for projects overview.
    Returns HTML partial with stat cards.
    """
    # Count projects by status (exclude deleted)
    total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar()
    upcoming_projects = db.query(func.count(Project.id)).filter_by(status="upcoming").scalar()
    active_projects = db.query(func.count(Project.id)).filter_by(status="active").scalar()
    on_hold_projects = db.query(func.count(Project.id)).filter_by(status="on_hold").scalar()
    completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar()

    # Count total locations across all projects
    total_locations = db.query(func.count(MonitoringLocation.id)).scalar()

    # Count assigned units
    assigned_units = db.query(func.count(UnitAssignment.id)).filter_by(
        status="active"
    ).scalar()

    # Count active recording sessions
    active_sessions = db.query(func.count(MonitoringSession.id)).filter_by(
        status="recording"
    ).scalar()

    return templates.TemplateResponse("partials/projects/project_stats.html", {
        "request": request,
        "total_projects": total_projects,
        "active_projects": active_projects,
        "upcoming_projects": upcoming_projects,
        "on_hold_projects": on_hold_projects,
        "completed_projects": completed_projects,
        "total_locations": total_locations,
        "assigned_units": assigned_units,
        "active_sessions": active_sessions,
    })


# ============================================================================
# Project Search (Smart Autocomplete)
# ============================================================================

def _build_project_display(project: Project) -> str:
    """Build display string from project fields: 'xxxx-YY - Client - Name'"""
    parts = []
    if project.project_number:
        parts.append(project.project_number)
    if project.client_name:
        parts.append(project.client_name)
    if project.name:
        parts.append(project.name)
    return " - ".join(parts) if parts else project.id


@router.get("/search", response_class=HTMLResponse)
async def search_projects(
    request: Request,
    q: str = Query("", description="Search term"),
    db: Session = Depends(get_db),
    limit: int = Query(10, le=50),
):
    """
    Fuzzy search across project fields for autocomplete.
    Searches: project_number, client_name, name (project/site name)
    Returns HTML partial for HTMX dropdown.
    """
    if not q.strip():
        # Return recent active projects when no search term
        projects = db.query(Project).filter(
            Project.status.notin_(["archived", "deleted"])
        ).order_by(Project.updated_at.desc()).limit(limit).all()
    else:
        search_term = f"%{q}%"
        projects = db.query(Project).filter(
            and_(
                Project.status.notin_(["archived", "deleted"]),
                or_(
                    Project.project_number.ilike(search_term),
                    Project.client_name.ilike(search_term),
                    Project.name.ilike(search_term),
                )
            )
        ).order_by(Project.updated_at.desc()).limit(limit).all()

    # Build display data for each project
    projects_data = [{
        "id": p.id,
        "project_number": p.project_number,
        "client_name": p.client_name,
        "name": p.name,
        "display": _build_project_display(p),
        "status": p.status,
    } for p in projects]

    return templates.TemplateResponse("partials/project_search_results.html", {
        "request": request,
        "projects": projects_data,
        "query": q,
        "show_create": len(projects) == 0 and q.strip(),
    })


@router.get("/search-json")
async def search_projects_json(
    q: str = Query("", description="Search term"),
    db: Session = Depends(get_db),
    limit: int = Query(10, le=50),
):
    """
    Fuzzy search across project fields - JSON response.
    For programmatic/API consumption.
    """
    if not q.strip():
        projects = db.query(Project).filter(
            Project.status.notin_(["archived", "deleted"])
        ).order_by(Project.updated_at.desc()).limit(limit).all()
    else:
        search_term = f"%{q}%"
        projects = db.query(Project).filter(
            and_(
                Project.status.notin_(["archived", "deleted"]),
                or_(
                    Project.project_number.ilike(search_term),
                    Project.client_name.ilike(search_term),
                    Project.name.ilike(search_term),
                )
            )
        ).order_by(Project.updated_at.desc()).limit(limit).all()

    return [{
        "id": p.id,
        "project_number": p.project_number,
        "client_name": p.client_name,
        "name": p.name,
        "display": _build_project_display(p),
        "status": p.status,
    } for p in projects]


# ============================================================================
# Project CRUD
# ============================================================================

@router.post("/create")
async def create_project(request: Request, db: Session = Depends(get_db)):
    """
    Create a new project.
    Expects form data with project details.
    """
    form_data = await request.form()

    project = Project(
        id=str(uuid.uuid4()),
        project_number=form_data.get("project_number"),  # TMI ID: xxxx-YY format
        name=form_data.get("name"),
        description=form_data.get("description"),
        project_type_id=form_data.get("project_type_id") or "",
        status="active",
        client_name=form_data.get("client_name"),
        site_address=form_data.get("site_address"),
        site_coordinates=form_data.get("site_coordinates"),
        start_date=datetime.fromisoformat(form_data.get("start_date")) if form_data.get("start_date") else None,
        end_date=datetime.fromisoformat(form_data.get("end_date")) if form_data.get("end_date") else None,
    )

    db.add(project)
    db.commit()
    db.refresh(project)

    return JSONResponse({
        "success": True,
        "project_id": project.id,
        "message": f"Project '{project.name}' created successfully",
    })


@router.get("/deleted")
async def list_deleted_projects(db: Session = Depends(get_db)):
    """List all soft-deleted projects."""
    projects = (
        db.query(Project)
        .filter(Project.status == "deleted")
        .order_by(Project.deleted_at.desc())
        .all()
    )
    return JSONResponse([
        {
            "id": p.id,
            "name": p.name,
            "client_name": p.client_name,
            "deleted_at": p.deleted_at.isoformat() if p.deleted_at else None,
        }
        for p in projects
    ])


@router.post("/{project_id}/restore")
async def restore_project(project_id: str, db: Session = Depends(get_db)):
    """Restore a soft-deleted project back to active."""
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")
    if project.status != "deleted":
        raise HTTPException(status_code=400, detail="Project is not deleted")
    project.status = "active"
    project.deleted_at = None
    db.commit()
    return {"success": True, "message": f"Project '{project.name}' restored."}


# ── Project merge ──────────────────────────────────────────────────────────────
# Consolidate a duplicate project into another.  Common after the
# metadata-backfill parser creates near-duplicate projects from name
# variations operators typed on the BW device.
# See backend/services/project_merge.py for the merge logic.

@router.get("/{source_id}/merge_preview")
async def project_merge_preview(
    source_id: str,
    target_id: str,
    db: Session = Depends(get_db),
):
    """Preview what the merge will do — used by the confirmation modal.
    No writes."""
    from backend.services import project_merge as pm
    preview = pm.preview(db, source_id, target_id)
    return {
        "source_project_id":         preview.source_project_id,
        "source_project_name":       preview.source_project_name,
        "target_project_id":         preview.target_project_id,
        "target_project_name":       preview.target_project_name,
        "total_assignments_moving":  preview.total_assignments_moving,
        "total_sessions_moving":     preview.total_sessions_moving,
        "total_data_files_moving":   preview.total_data_files_moving,
        "modules_to_add":            preview.modules_to_add,
        "warnings":                  preview.warnings,
        "location_plans": [
            {
                "source_id":          p.source_id,
                "source_name":        p.source_name,
                "target_id":          p.target_id,
                "target_name":        p.target_name,
                "action":             p.action,
                "assignments_moving": p.assignments_moving,
                "sessions_moving":    p.sessions_moving,
            }
            for p in preview.location_plans
        ],
    }


@router.get("/admin/duplicate_pairs")
async def get_duplicate_pairs(
    threshold: float = 0.85,
    max_pairs: int = 200,
    db: Session = Depends(get_db),
):
    """Return all active-project pairs whose names fuzzy-match above the
    threshold.  Used by the Tidy page to surface duplicates that would
    otherwise have to be hunted down one at a time.

    Each pair carries a suggested merge-target with the reasoning so the
    operator can decide direction with one click.
    """
    from backend.services import project_tidy as pt
    pairs = pt.find_duplicate_pairs(db, threshold=threshold, max_pairs=max_pairs)

    def _ps(p):
        return {
            "id":               p.id,
            "name":             p.name,
            "project_number":   p.project_number,
            "client_name":      p.client_name,
            "source":           p.source,
            "status":           p.status,
            "location_count":   p.location_count,
            "assignment_count": p.assignment_count,
        }

    return {
        "pairs": [
            {
                "a":                   _ps(pair.a),
                "b":                   _ps(pair.b),
                "score":               round(pair.score, 3),
                "suggested_target_id": pair.suggested_target_id,
                "reason":              pair.reason,
            }
            for pair in pairs
        ],
        "threshold": threshold,
    }


@router.post("/{source_id}/merge_into")
async def project_merge_execute(
    source_id: str,
    target_id: str,
    db: Session = Depends(get_db),
):
    """Execute the merge.  Source project gets soft-deleted; all its
    locations / assignments / sessions / data_files / modules move to
    the target.  Same-named locations consolidate."""
    from backend.services import project_merge as pm
    result = pm.execute(db, source_id, target_id, decided_by="operator")
    return {
        "success":                True,
        "source_project_id":      result.source_project_id,
        "target_project_id":      result.target_project_id,
        "assignments_moved":      result.assignments_moved,
        "locations_moved":        result.locations_moved,
        "locations_consolidated": result.locations_consolidated,
        "sessions_moved":         result.sessions_moved,
        "data_files_moved":       result.data_files_moved,
        "modules_added":          result.modules_added,
        "audit_rows_written":     result.audit_rows_written,
    }


@router.get("/{project_id}")
async def get_project(project_id: str, db: Session = Depends(get_db)):
    """
    Get project details by ID.
    Returns JSON with full project data.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
    modules = _get_project_modules(project.id, db)

    return {
        "id": project.id,
        "project_number": project.project_number,
        "name": project.name,
        "description": project.description,
        "project_type_id": project.project_type_id,
        "project_type_name": project_type.name if project_type else None,
        "modules": modules,
        "status": project.status,
        "client_name": project.client_name,
        "site_address": project.site_address,
        "site_coordinates": project.site_coordinates,
        "start_date": project.start_date.isoformat() if project.start_date else None,
        "end_date": project.end_date.isoformat() if project.end_date else None,
        "data_collection_mode": project.data_collection_mode or "manual",
        "created_at": project.created_at.isoformat(),
        "updated_at": project.updated_at.isoformat(),
    }


@router.get("/{project_id}/modules")
async def list_project_modules(project_id: str, db: Session = Depends(get_db)):
    """Return enabled modules for a project, including metadata from MODULES registry."""
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")
    rows = db.query(ProjectModule).filter_by(project_id=project_id, enabled=True).all()
    active = {r.module_type for r in rows}
    return {
        "active": list(active),
        "available": [
            {"module_type": k, **v}
            for k, v in MODULES.items()
            if k not in active
        ],
    }


@router.post("/{project_id}/modules")
async def add_project_module(project_id: str, request: Request, db: Session = Depends(get_db)):
    """Add a module to a project. Body: {module_type: str}"""
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")
    data = await request.json()
    module_type = data.get("module_type")
    if not module_type or module_type not in MODULES:
        raise HTTPException(status_code=400, detail=f"Unknown module type: {module_type}")
    existing = db.query(ProjectModule).filter_by(project_id=project_id, module_type=module_type).first()
    if existing:
        existing.enabled = True
    else:
        db.add(ProjectModule(
            id=str(uuid.uuid4()),
            project_id=project_id,
            module_type=module_type,
            enabled=True,
        ))
    db.commit()
    return {"ok": True, "modules": _get_project_modules(project_id, db)}


@router.delete("/{project_id}/modules/{module_type}")
async def remove_project_module(project_id: str, module_type: str, db: Session = Depends(get_db)):
    """Disable a module on a project. Data is not deleted."""
    row = db.query(ProjectModule).filter_by(project_id=project_id, module_type=module_type).first()
    if row:
        row.enabled = False
        db.commit()
    return {"ok": True, "modules": _get_project_modules(project_id, db)}


@router.put("/{project_id}")
async def update_project(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Update project details.
    Expects JSON body with fields to update.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    data = await request.json()

    # Update fields if provided
    if "name" in data:
        project.name = data["name"]
    if "description" in data:
        project.description = data["description"]
    if "status" in data:
        project.status = data["status"]
        # Cancel pending scheduled actions when archiving
        if data["status"] == "archived":
            db.query(ScheduledAction).filter(
                and_(
                    ScheduledAction.project_id == project_id,
                    ScheduledAction.execution_status == "pending",
                )
            ).update({"execution_status": "cancelled"})
    if "client_name" in data:
        project.client_name = data["client_name"]
    if "site_address" in data:
        project.site_address = data["site_address"]
    if "site_coordinates" in data:
        project.site_coordinates = data["site_coordinates"]
    if "start_date" in data:
        project.start_date = datetime.fromisoformat(data["start_date"]) if data["start_date"] else None
    if "end_date" in data:
        project.end_date = datetime.fromisoformat(data["end_date"]) if data["end_date"] else None
    if "data_collection_mode" in data and data["data_collection_mode"] in ("remote", "manual"):
        project.data_collection_mode = data["data_collection_mode"]

    project.updated_at = datetime.utcnow()

    db.commit()

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


@router.delete("/{project_id}")
async def delete_project(project_id: str, db: Session = Depends(get_db)):
    """
    Soft-delete a project. Sets status='deleted' and records deleted_at timestamp.
    Data will be permanently removed after 60 days (or via /permanent endpoint).
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    project.status = "deleted"
    project.deleted_at = datetime.utcnow()
    project.updated_at = datetime.utcnow()

    # Cancel all pending scheduled actions
    db.query(ScheduledAction).filter(
        and_(
            ScheduledAction.project_id == project_id,
            ScheduledAction.execution_status == "pending",
        )
    ).update({"execution_status": "cancelled"})

    db.commit()

    return {"success": True, "message": "Project deleted. Data will be permanently removed after 60 days."}


@router.delete("/{project_id}/permanent")
async def permanently_delete_project(project_id: str, db: Session = Depends(get_db)):
    """
    Hard-delete a project and all related data. Only allowed when status='deleted'.
    Removes: locations, assignments, sessions, scheduled actions, recurring schedules.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")
    if project.status != "deleted":
        raise HTTPException(status_code=400, detail="Project must be soft-deleted before permanent deletion.")

    # Delete related data
    db.query(RecurringSchedule).filter_by(project_id=project_id).delete()
    db.query(ScheduledAction).filter_by(project_id=project_id).delete()
    db.query(MonitoringSession).filter_by(project_id=project_id).delete()
    db.query(UnitAssignment).filter_by(project_id=project_id).delete()
    db.query(MonitoringLocation).filter_by(project_id=project_id).delete()
    db.delete(project)
    db.commit()

    return {"success": True, "message": "Project permanently deleted."}


@router.post("/{project_id}/hold")
async def hold_project(project_id: str, db: Session = Depends(get_db)):
    """
    Put a project on hold. Pauses without archiving; assignments and schedules remain.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    project.status = "on_hold"
    project.updated_at = datetime.utcnow()

    # Cancel pending scheduled actions so they don't appear in dashboards or fire
    db.query(ScheduledAction).filter(
        and_(
            ScheduledAction.project_id == project_id,
            ScheduledAction.execution_status == "pending",
        )
    ).update({"execution_status": "cancelled"})

    db.commit()

    return {"success": True, "message": "Project put on hold."}


@router.post("/{project_id}/unhold")
async def unhold_project(project_id: str, db: Session = Depends(get_db)):
    """
    Resume a project that was on hold.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    project.status = "active"
    project.updated_at = datetime.utcnow()
    db.commit()

    return {"success": True, "message": "Project resumed."}


# ============================================================================
# Project Dashboard Data
# ============================================================================

@router.get("/{project_id}/dashboard", response_class=HTMLResponse)
async def get_project_dashboard(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Get project dashboard data.
    Returns HTML partial with project summary.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()

    # Get locations
    locations = db.query(MonitoringLocation).filter_by(project_id=project_id).all()

    # Get assigned units with details (active = assigned_until IS NULL)
    assignments = db.query(UnitAssignment).filter(
        and_(
            UnitAssignment.project_id == project_id,
            UnitAssignment.assigned_until == None,
        )
    ).all()

    assigned_units = []
    for assignment in assignments:
        unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
        if unit:
            assigned_units.append({
                "assignment": assignment,
                "unit": unit,
            })

    # Get active recording sessions
    active_sessions = db.query(MonitoringSession).filter(
        and_(
            MonitoringSession.project_id == project_id,
            MonitoringSession.status == "recording",
        )
    ).all()

    # Get completed sessions count
    completed_sessions_count = db.query(func.count(MonitoringSession.id)).filter(
        and_(
            MonitoringSession.project_id == project_id,
            MonitoringSession.status == "completed",
        )
    ).scalar()

    # Get upcoming scheduled actions
    upcoming_actions = db.query(ScheduledAction).filter(
        and_(
            ScheduledAction.project_id == project_id,
            ScheduledAction.execution_status == "pending",
            ScheduledAction.scheduled_time > datetime.utcnow(),
        )
    ).order_by(ScheduledAction.scheduled_time).limit(5).all()

    return templates.TemplateResponse("partials/projects/project_dashboard.html", {
        "request": request,
        "project": project,
        "project_type": project_type,
        "modules": _get_project_modules(project_id, db),
        "locations": locations,
        "assigned_units": assigned_units,
        "active_sessions": active_sessions,
        "completed_sessions_count": completed_sessions_count,
        "upcoming_actions": upcoming_actions,
    })


@router.get("/{project_id}/live-stats")
async def get_project_live_stats(project_id: str, db: Session = Depends(get_db)):
    """Live SLM readings for each sound NRL in the project.

    Reads SLMM's cached per-unit status snapshots (the same source the client
    portal uses) and returns one entry per active sound location. Powers the
    Overview tab's live monitoring section. Internal-only, so it includes
    device-health fields (battery, power source, reachability) the portal hides.
    """
    import os
    import asyncio
    import httpx

    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    now = datetime.utcnow()
    locations = (
        db.query(MonitoringLocation)
        .filter(
            MonitoringLocation.project_id == project_id,
            MonitoringLocation.location_type == "sound",
            MonitoringLocation.removed_at.is_(None),
        )
        .order_by(MonitoringLocation.sort_order, MonitoringLocation.name)
        .all()
    )

    # Active SLM unit per location (mirrors portal.active_unit_for_location).
    def _active_unit(loc_id: str):
        asg = (
            db.query(UnitAssignment)
            .filter(
                UnitAssignment.location_id == loc_id,
                UnitAssignment.status == "active",
                UnitAssignment.device_type == "slm",
                or_(
                    UnitAssignment.assigned_until.is_(None),
                    UnitAssignment.assigned_until > now,
                ),
            )
            .order_by(UnitAssignment.assigned_at.desc())
            .first()
        )
        return asg.unit_id if asg else None

    loc_units = [(loc, _active_unit(loc.id)) for loc in locations]

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

    async def _fetch(unit_id):
        if not unit_id:
            return None, "no_device"
        try:
            async with httpx.AsyncClient(timeout=5.0) as hc:
                r = await hc.get(f"{slmm_base}/api/nl43/{unit_id}/status")
        except Exception:
            return None, "unreachable"
        if r.status_code != 200:
            return None, "no_data"
        return (r.json() or {}).get("data") or {}, None

    results = await asyncio.gather(*[_fetch(u) for (_, u) in loc_units])

    out = []
    for (loc, unit_id), (data, reason) in zip(loc_units, results):
        entry = {
            "id": loc.id,
            "name": loc.name,
            "unit_id": unit_id,
        }
        if data is None:
            entry["reason"] = reason
            entry["measurement_state"] = None
        else:
            entry.update(
                {
                    "measurement_state": data.get("measurement_state"),
                    "leq": data.get("leq"),
                    "lp": data.get("lp"),
                    "lmax": data.get("lmax"),
                    "last_seen": data.get("last_seen"),
                    "battery_level": data.get("battery_level"),
                    "power_source": data.get("power_source"),
                    "is_reachable": data.get("is_reachable"),
                    "connection_state": data.get("connection_state"),
                }
            )
        out.append(entry)

    return {"status": "ok", "locations": out}


# ============================================================================
# Project Types
# ============================================================================

@router.get("/{project_id}/header", response_class=HTMLResponse)
async def get_project_header(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db)
):
    """
    Get project header information for dynamic display.
    Returns HTML partial with project name, status, and type.
    """
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()

    return templates.TemplateResponse("partials/projects/project_header.html", {
        "request": request,
        "project": project,
        "project_type": project_type,
        "modules": _get_project_modules(project_id, db),
    })


@router.get("/{project_id}/units", response_class=HTMLResponse)
async def get_project_units(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Get all units assigned to this project's locations.
    Returns HTML partial with unit list.
    """
    from backend.models import DataFile

    # Get all assignments for this project (active = assigned_until IS NULL)
    assignments = db.query(UnitAssignment).filter(
        and_(
            UnitAssignment.project_id == project_id,
            UnitAssignment.assigned_until == None,
        )
    ).all()

    # Enrich with unit and location details
    units_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()

        # Count sessions for this assignment
        session_count = db.query(func.count(MonitoringSession.id)).filter_by(
            location_id=assignment.location_id,
            unit_id=assignment.unit_id,
        ).scalar()

        # Count files from sessions
        file_count = db.query(func.count(DataFile.id)).join(
            MonitoringSession,
            DataFile.session_id == MonitoringSession.id
        ).filter(
            MonitoringSession.location_id == assignment.location_id,
            MonitoringSession.unit_id == assignment.unit_id,
        ).scalar()

        # Check if currently recording
        active_session = db.query(MonitoringSession).filter(
            and_(
                MonitoringSession.location_id == assignment.location_id,
                MonitoringSession.unit_id == assignment.unit_id,
                MonitoringSession.status == "recording",
            )
        ).first()

        units_data.append({
            "assignment": assignment,
            "unit": unit,
            "location": location,
            "session_count": session_count,
            "file_count": file_count,
            "active_session": active_session,
        })

    # Get project type for label context
    project = db.query(Project).filter_by(id=project_id).first()
    project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first() if project else None

    return templates.TemplateResponse("partials/projects/unit_list.html", {
        "request": request,
        "project_id": project_id,
        "units": units_data,
        "project_type": project_type,
    })


@router.get("/{project_id}/schedules", response_class=HTMLResponse)
async def get_project_schedules(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
    status: Optional[str] = Query(None),
):
    """
    Get scheduled actions for this project.
    Returns HTML partial with schedule list.
    Optional status filter: pending, completed, failed, cancelled
    """
    query = db.query(ScheduledAction).filter_by(project_id=project_id)

    # Filter by status if provided
    if status:
        query = query.filter(ScheduledAction.execution_status == status)

    # For pending actions, show soonest first (ascending)
    # For completed/failed, show most recent first (descending)
    if status == "pending":
        schedules = query.order_by(ScheduledAction.scheduled_time.asc()).all()
    else:
        schedules = query.order_by(ScheduledAction.scheduled_time.desc()).all()

    # Enrich with location details and group by date
    schedules_by_date = OrderedDict()
    for schedule in schedules:
        location = None
        if schedule.location_id:
            location = db.query(MonitoringLocation).filter_by(id=schedule.location_id).first()

        # Get local date for grouping
        if schedule.scheduled_time:
            local_dt = utc_to_local(schedule.scheduled_time)
            date_key = local_dt.strftime("%Y-%m-%d")
            date_display = local_dt.strftime("%A, %B %d, %Y")  # "Wednesday, January 22, 2026"
        else:
            date_key = "unknown"
            date_display = "Unknown Date"

        if date_key not in schedules_by_date:
            schedules_by_date[date_key] = {
                "date_display": date_display,
                "date_key": date_key,
                "actions": [],
            }

        # Parse module_response for display
        result_data = None
        if schedule.module_response:
            try:
                result_data = json.loads(schedule.module_response)
            except json.JSONDecodeError:
                pass

        schedules_by_date[date_key]["actions"].append({
            "schedule": schedule,
            "location": location,
            "result": result_data,
        })

    project = db.query(Project).filter_by(id=project_id).first()
    project_status = project.status if project else "active"

    return templates.TemplateResponse("partials/projects/schedule_list.html", {
        "request": request,
        "project_id": project_id,
        "schedules_by_date": schedules_by_date,
        "project_status": project_status,
    })


@router.post("/{project_id}/schedules/{schedule_id}/execute")
async def execute_scheduled_action(
    project_id: str,
    schedule_id: str,
    db: Session = Depends(get_db),
):
    """
    Manually execute a scheduled action now.
    """
    from backend.services.scheduler import get_scheduler

    action = db.query(ScheduledAction).filter_by(
        id=schedule_id,
        project_id=project_id,
    ).first()

    if not action:
        raise HTTPException(status_code=404, detail="Action not found")

    if action.execution_status != "pending":
        raise HTTPException(
            status_code=400,
            detail=f"Action is not pending (status: {action.execution_status})",
        )

    # Execute via scheduler service
    scheduler = get_scheduler()
    result = await scheduler.execute_action_by_id(schedule_id)

    # Refresh from DB to get updated status
    db.refresh(action)

    return JSONResponse({
        "success": result.get("success", False),
        "message": f"Action executed: {action.action_type}",
        "result": result,
        "action": {
            "id": action.id,
            "execution_status": action.execution_status,
            "executed_at": action.executed_at.isoformat() if action.executed_at else None,
            "error_message": action.error_message,
        },
    })


@router.post("/{project_id}/schedules/{schedule_id}/cancel")
async def cancel_scheduled_action(
    project_id: str,
    schedule_id: str,
    db: Session = Depends(get_db),
):
    """
    Cancel a pending scheduled action.
    """
    action = db.query(ScheduledAction).filter_by(
        id=schedule_id,
        project_id=project_id,
    ).first()

    if not action:
        raise HTTPException(status_code=404, detail="Action not found")

    if action.execution_status != "pending":
        raise HTTPException(
            status_code=400,
            detail=f"Can only cancel pending actions (status: {action.execution_status})",
        )

    action.execution_status = "cancelled"
    db.commit()

    return JSONResponse({
        "success": True,
        "message": "Action cancelled successfully",
    })


@router.get("/{project_id}/sessions", response_class=HTMLResponse)
async def get_project_sessions(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
    status: Optional[str] = Query(None),
):
    """
    Get all recording sessions for this project.
    Returns HTML partial with session list.
    Optional status filter: recording, completed, paused, failed
    """
    query = db.query(MonitoringSession).filter_by(project_id=project_id)

    # Filter by status if provided
    if status:
        query = query.filter(MonitoringSession.status == status)

    sessions = query.order_by(MonitoringSession.started_at.desc()).all()

    # Enrich with unit, location, and effective time window details
    sessions_data = []
    for session in sessions:
        unit = None
        location = None

        if session.unit_id:
            unit = db.query(RosterUnit).filter_by(id=session.unit_id).first()
        if session.location_id:
            location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()

        # Compute "Effective: date time → date time" string when period hours are set
        effective_range = None
        if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
            from datetime import date as _date
            local_start = utc_to_local(session.started_at)
            start_day = session.report_date if session.report_date else local_start.date()
            sh = session.period_start_hour
            eh = session.period_end_hour

            def _fmt_h(h):
                ampm = "AM" if h < 12 else "PM"
                h12  = h % 12 or 12
                return f"{h12}:00 {ampm}"

            start_str = f"{start_day.month}/{start_day.day} {_fmt_h(sh)}"
            if eh > sh:  # same calendar day
                end_day = start_day
            else:        # crosses midnight
                from datetime import timedelta as _td
                end_day = start_day + _td(days=1)
            end_str = f"{end_day.month}/{end_day.day} {_fmt_h(eh)}"
            effective_range = f"{start_str} → {end_str}"

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

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


@router.get("/{project_id}/sessions-calendar", response_class=HTMLResponse)
async def get_sessions_calendar(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
    month: Optional[int] = Query(None),
    year: Optional[int] = Query(None),
):
    """
    Monthly calendar view of monitoring sessions.
    Color-coded by NRL location.  Returns HTML partial.
    """
    from calendar import monthrange
    from datetime import date as _date, timedelta as _td

    # Default to current month
    now_local = utc_to_local(datetime.utcnow())
    if not year:
        year = now_local.year
    if not month:
        month = now_local.month

    # Clamp month to valid range
    month = max(1, min(12, month))

    # Load all sessions for this project
    sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()

    # Build location -> color map (deterministic)
    PALETTE = [
        "#f97316", "#3b82f6", "#10b981", "#8b5cf6",
        "#ec4899", "#14b8a6", "#f59e0b", "#6366f1",
        "#ef4444", "#84cc16",
    ]
    loc_ids = sorted({s.location_id for s in sessions if s.location_id})
    loc_color = {lid: PALETTE[i % len(PALETTE)] for i, lid in enumerate(loc_ids)}

    # Load location names
    loc_names = {}
    for lid in loc_ids:
        loc = db.query(MonitoringLocation).filter_by(id=lid).first()
        if loc:
            loc_names[lid] = loc.name

    # Build calendar grid bounds first (needed for session spanning logic)
    first_day = _date(year, month, 1)
    last_day = _date(year, month, monthrange(year, month)[1])
    days_before = (first_day.isoweekday() % 7)
    grid_start = first_day - _td(days=days_before)
    days_after = 6 - (last_day.isoweekday() % 7)
    grid_end = last_day + _td(days=days_after)

    def _period_hours(s):
        """Return (start_hour, end_hour) for a session, falling back to period_type defaults."""
        psh, peh = s.period_start_hour, s.period_end_hour
        if psh is None or peh is None:
            if s.period_type and "night" in s.period_type:
                return 19, 7
            if s.period_type and "day" in s.period_type:
                return 7, 19
        return psh, peh

    # Build day -> list of gantt segments
    day_sessions: dict = {}
    for s in sessions:
        if not s.started_at:
            continue
        local_start = utc_to_local(s.started_at)
        local_end = utc_to_local(s.stopped_at) if s.stopped_at else now_local
        span_start = local_start.date()
        span_end = local_end.date()
        psh, peh = _period_hours(s)

        cur_d = span_start
        while cur_d <= span_end:
            if grid_start <= cur_d <= grid_end:
                # Device bar bounds (hours 0–24 within this day)
                dev_sh = (local_start.hour + local_start.minute / 60.0) if cur_d == span_start else 0.0
                dev_eh = (local_end.hour + local_end.minute / 60.0) if cur_d == span_end else 24.0

                # Effective window within this day
                eff_sh = eff_eh = None
                if psh is not None and peh is not None:
                    if psh < peh:
                        # Day window e.g. 7→19
                        eff_sh, eff_eh = float(psh), float(peh)
                    else:
                        # Night window crossing midnight e.g. 19→7
                        if cur_d == span_start:
                            eff_sh, eff_eh = float(psh), 24.0
                        else:
                            eff_sh, eff_eh = 0.0, float(peh)

                # Format tooltip labels
                def _fmt_h(h):
                    hh = int(h) % 24
                    mm = int((h % 1) * 60)
                    suffix = "AM" if hh < 12 else "PM"
                    return f"{hh % 12 or 12}:{mm:02d} {suffix}"

                if cur_d not in day_sessions:
                    day_sessions[cur_d] = []
                day_sessions[cur_d].append({
                    "session_id": s.id,
                    "label": s.session_label or f"Session {s.id[:8]}",
                    "location_id": s.location_id,
                    "location_name": loc_names.get(s.location_id, "Unknown"),
                    "color": loc_color.get(s.location_id, "#9ca3af"),
                    "status": s.status,
                    "period_type": s.period_type,
                    # Gantt bar percentages (0–100 scale across 24 hours)
                    "dev_start_pct": round(dev_sh / 24 * 100, 1),
                    "dev_width_pct": max(1.5, round((dev_eh - dev_sh) / 24 * 100, 1)),
                    "eff_start_pct": round(eff_sh / 24 * 100, 1) if eff_sh is not None else None,
                    "eff_width_pct": max(1.0, round((eff_eh - eff_sh) / 24 * 100, 1)) if eff_sh is not None else None,
                    "dev_start_label": _fmt_h(dev_sh),
                    "dev_end_label": _fmt_h(dev_eh),
                    "eff_start_label": f"{int(psh):02d}:00" if eff_sh is not None else None,
                    "eff_end_label": f"{int(peh):02d}:00" if eff_sh is not None else None,
                })
            cur_d += _td(days=1)

    weeks = []
    cur = grid_start
    while cur <= grid_end:
        week = []
        for _ in range(7):
            week.append({
                "date": cur,
                "in_month": cur.month == month,
                "is_today": cur == now_local.date(),
                "sessions": day_sessions.get(cur, []),
            })
            cur += _td(days=1)
        weeks.append(week)

    # Prev/next month navigation
    prev_month = month - 1 if month > 1 else 12
    prev_year  = year if month > 1 else year - 1
    next_month = month + 1 if month < 12 else 1
    next_year  = year if month < 12 else year + 1

    import calendar as _cal
    month_name = _cal.month_name[month]

    # Legend: only locations that have sessions this month
    used_lids = {s["location_id"] for day in day_sessions.values() for s in day}
    legend = [
        {"location_id": lid, "name": loc_names.get(lid, lid[:8]), "color": loc_color[lid]}
        for lid in loc_ids if lid in used_lids
    ]

    return templates.TemplateResponse("partials/projects/sessions_calendar.html", {
        "request": request,
        "project_id": project_id,
        "weeks": weeks,
        "month": month,
        "year": year,
        "month_name": month_name,
        "prev_month": prev_month,
        "prev_year": prev_year,
        "next_month": next_month,
        "next_year": next_year,
        "legend": legend,
    })


@router.get("/{project_id}/ftp-browser", response_class=HTMLResponse)
async def get_ftp_browser(
    project_id: str,
    request: Request,
    location_id: Optional[str] = None,
    db: Session = Depends(get_db),
):
    """
    Get FTP browser interface for downloading files from assigned SLMs.
    Returns HTML partial with FTP browser. Sound Monitoring projects only.

    When `location_id` is given, scope to just the unit(s) assigned to that NRL
    (used by the per-NRL Data Files tab, which mirrors the project-wide tab).
    """
    from backend.models import DataFile

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

    # Active assignments for this project (active = assigned_until IS NULL),
    # optionally scoped to a single NRL/location.
    q = db.query(UnitAssignment).filter(
        and_(
            UnitAssignment.project_id == project_id,
            UnitAssignment.assigned_until == None,
        )
    )
    if location_id:
        q = q.filter(UnitAssignment.location_id == location_id)
    assignments = q.all()

    # Enrich with unit and location details
    units_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()

        # Only include SLM units
        if unit and unit.device_type == "slm":
            units_data.append({
                "assignment": assignment,
                "unit": unit,
                "location": location,
            })

    return templates.TemplateResponse("partials/projects/ftp_browser.html", {
        "request": request,
        "project_id": project_id,
        "units": units_data,
    })


@router.post("/{project_id}/ftp-download-to-server")
async def ftp_download_to_server(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Download a single file from an SLM to the server via FTP.

    NRL measurement files (.rnh / _Leq_ .rnd) are routed through the shared NRL
    ingest so the session is parsed and attributed to the unit (a lone .rnh still
    yields the real recording window + duration).  Any other file type — or a
    unit with no location — falls back to a generic stored DataFile, preserving
    the original behaviour.  Sound Monitoring projects only.
    """
    import httpx
    import os
    import hashlib
    from pathlib import Path
    from backend.models import DataFile

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

    data = await request.json()
    unit_id = data.get("unit_id")
    remote_path = data.get("remote_path")
    location_id = data.get("location_id")

    if not unit_id or not remote_path:
        raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")

    filename = os.path.basename(remote_path)

    # Download the file from SLMM
    SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
    try:
        async with httpx.AsyncClient(timeout=300.0) as client:
            response = await client.post(
                f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download",
                json={"remote_path": remote_path}
            )
    except httpx.TimeoutException:
        raise HTTPException(status_code=504, detail="Timeout downloading file from SLM")
    except Exception as e:
        logger.error(f"Error reaching SLMM for file download: {e}")
        raise HTTPException(status_code=502, detail=f"Failed to reach SLMM: {str(e)}")

    if not response.is_success:
        raise HTTPException(
            status_code=response.status_code,
            detail=f"Failed to download from SLMM: {response.text}",
        )
    file_content = response.content

    # NRL measurement file + known location → shared ingest (parsed + attributed).
    from backend.routers.project_locations import (
        _ingest_file_entries, IngestError, _is_wanted_nrl_file,
    )
    if location_id and _is_wanted_nrl_file(filename):
        location = db.query(MonitoringLocation).filter_by(id=location_id).first()
        if location:
            try:
                result = _ingest_file_entries(
                    location, [(filename, file_content)], db,
                    source="ftp_manual", dedupe=False, unit_id=unit_id,
                )
            except IngestError as e:
                raise HTTPException(status_code=400, detail=str(e))
            return {
                "success": True,
                "message": f"Imported {filename} as NRL measurement data",
                "ingested": True,
                "session_id": result["session_id"],
                "file_size": len(file_content),
                "started_at": result["started_at"],
                "stopped_at": result["stopped_at"],
                "duration_seconds": result["duration_seconds"],
            }

    # --- Generic path: any other file type (or no location) — store as-is ---
    session = db.query(MonitoringSession).filter(
        and_(
            MonitoringSession.project_id == project_id,
            MonitoringSession.location_id == location_id,
            MonitoringSession.unit_id == unit_id,
            MonitoringSession.status.in_(["recording", "paused"])
        )
    ).first()

    if not session:
        _ftp_unit = db.query(RosterUnit).filter_by(id=unit_id).first()
        session = MonitoringSession(
            id=str(uuid.uuid4()),
            project_id=project_id,
            location_id=location_id,
            unit_id=unit_id,
            session_type="sound",  # SLMs are sound monitoring devices
            status="completed",
            started_at=datetime.utcnow(),
            stopped_at=datetime.utcnow(),
            device_model=_ftp_unit.slm_model if _ftp_unit else None,
            session_metadata='{"source": "ftp_download", "note": "Auto-created for FTP download"}'
        )
        db.add(session)
        db.commit()
        db.refresh(session)

    ext = os.path.splitext(filename)[1].lower()
    file_type_map = {
        '.wav': 'audio', '.mp3': 'audio', '.flac': 'audio', '.m4a': 'audio', '.aac': 'audio',
        '.rnd': 'measurement',
        '.csv': 'data', '.txt': 'data', '.json': 'data', '.xml': 'data', '.dat': 'data',
        '.log': 'log',
        '.zip': 'archive', '.tar': 'archive', '.gz': 'archive', '.7z': 'archive', '.rar': 'archive',
        '.jpg': 'image', '.jpeg': 'image', '.png': 'image', '.gif': 'image',
        '.pdf': 'document', '.doc': 'document', '.docx': 'document',
    }
    file_type = file_type_map.get(ext, 'data')

    project_dir = Path(f"data/Projects/{project_id}/{session.id}")
    project_dir.mkdir(parents=True, exist_ok=True)
    file_path = project_dir / filename
    with open(file_path, 'wb') as f:
        f.write(file_content)
    checksum = hashlib.sha256(file_content).hexdigest()

    data_file = DataFile(
        id=str(uuid.uuid4()),
        session_id=session.id,
        file_path=str(file_path.relative_to("data")),  # Store relative to data/
        file_type=file_type,
        file_size_bytes=len(file_content),
        downloaded_at=datetime.utcnow(),
        checksum=checksum,
        file_metadata=json.dumps({
            "source": "ftp",
            "remote_path": remote_path,
            "unit_id": unit_id,
            "location_id": location_id,
        })
    )
    db.add(data_file)
    db.commit()

    return {
        "success": True,
        "message": f"Downloaded {filename} to server",
        "file_id": data_file.id,
        "file_path": str(file_path),
        "file_size": len(file_content),
    }


@router.post("/{project_id}/ftp-download-folder-to-server")
async def ftp_download_folder_to_server(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Download an entire Auto_#### measurement folder from an SLM to the server.

    Routes the downloaded ZIP through the shared NRL ingest — the same path the
    scheduled FTP pull, the daily cycle, and the manual SD-card upload use.  That
    means: keep the .rnh + Leq .rnd, parse the header (real recording start/stop
    + duration, percentile slot map, weightings), drop the 1-second _Lp_ files,
    and create one clean MonitoringSession attributed to the unit.  Sound
    Monitoring projects only.
    """
    import httpx
    import os

    _require_module(db.query(Project).filter_by(id=project_id).first(), "sound_monitoring", db)
    from backend.routers.project_locations import ingest_nrl_zip, IngestError

    data = await request.json()
    unit_id = data.get("unit_id")
    remote_path = data.get("remote_path")
    location_id = data.get("location_id")

    if not unit_id or not remote_path:
        raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
    if not location_id:
        raise HTTPException(
            status_code=400,
            detail=("This unit isn't assigned to a monitoring location. Assign it to an "
                    "NRL first so the downloaded measurement attaches to the right location."),
        )

    # Download the folder from SLMM (returns a ZIP of the Auto_#### folder)
    SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
    try:
        async with httpx.AsyncClient(timeout=600.0) as client:  # longer timeout for folders
            response = await client.post(
                f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download-folder",
                json={"remote_path": remote_path}
            )
    except httpx.TimeoutException:
        raise HTTPException(
            status_code=504,
            detail="Timeout downloading folder from SLM (large folders may take a while)",
        )
    except Exception as e:
        logger.error(f"Error reaching SLMM for folder download: {e}")
        raise HTTPException(status_code=502, detail=f"Failed to reach SLMM: {str(e)}")

    if not response.is_success:
        raise HTTPException(
            status_code=response.status_code,
            detail=f"Failed to download folder from SLMM: {response.text}",
        )

    # Ingest through the shared NRL core.  dedupe=False so a re-download of a
    # still-growing folder captures the latest intervals (matches manual upload).
    try:
        result = ingest_nrl_zip(
            location_id, response.content, db,
            source="ftp_manual", dedupe=False, unit_id=unit_id,
        )
    except IngestError as e:
        # No usable .rnd/.rnh in the folder, or unknown location.
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        logger.error(f"Error ingesting downloaded folder: {e}")
        raise HTTPException(status_code=500, detail=f"Failed to ingest downloaded folder: {str(e)}")

    folder_name = os.path.basename(remote_path.rstrip('/'))
    return {
        "success": True,
        "message": (
            f"Imported {result['leq_files']} Leq file(s) from {folder_name} "
            f"({result['files_imported']} stored; 1-second _Lp_ data skipped)"
        ),
        "folder_name": folder_name,
        "session_id": result["session_id"],
        "file_count": result["files_imported"],
        "leq_files": result["leq_files"],
        "started_at": result["started_at"],
        "stopped_at": result["stopped_at"],
        "duration_seconds": result["duration_seconds"],
    }


# ============================================================================
# Project Types
# ============================================================================

@router.get("/{project_id}/files-unified", response_class=HTMLResponse)
async def get_unified_files(
    project_id: str,
    request: Request,
    location_id: Optional[str] = None,
    db: Session = Depends(get_db),
):
    """
    Get unified view of all files in this project.
    Groups files by recording session with full metadata.
    Returns HTML partial with hierarchical file listing.

    When `location_id` is given, scope to a single NRL/location (used by the
    per-NRL Data Files tab so it mirrors the project-wide tab).
    """
    from backend.models import DataFile
    from pathlib import Path
    import json

    # Sessions for this project (optionally scoped to one NRL/location)
    q = db.query(MonitoringSession).filter_by(project_id=project_id)
    if location_id:
        q = q.filter(MonitoringSession.location_id == location_id)
    sessions = q.order_by(MonitoringSession.started_at.desc()).all()

    sessions_data = []
    for session in sessions:
        # Get files for this session
        files = db.query(DataFile).filter_by(session_id=session.id).all()

        # Skip sessions with no files
        if not files:
            continue

        # Get session context
        unit = None
        location = None
        if session.unit_id:
            unit = db.query(RosterUnit).filter_by(id=session.unit_id).first()
        if session.location_id:
            location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()

        files_data = []
        for file in files:
            # Check if file exists on disk
            file_path = Path("data") / file.file_path
            exists_on_disk = file_path.exists()

            # Get actual file size if exists
            actual_size = file_path.stat().st_size if exists_on_disk else None

            # Parse metadata JSON
            metadata = {}
            try:
                if file.file_metadata:
                    metadata = json.loads(file.file_metadata)
            except Exception as e:
                logger.warning(f"Failed to parse metadata for file {file.id}: {e}")

            files_data.append({
                "file": file,
                "exists_on_disk": exists_on_disk,
                "actual_size": actual_size,
                "metadata": metadata,
            })

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

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


@router.get("/{project_id}/files/{file_id}/download")
async def download_project_file(
    project_id: str,
    file_id: str,
    db: Session = Depends(get_db),
):
    """
    Download a data file from a project.
    Returns the file for download.
    """
    from backend.models import DataFile
    from fastapi.responses import FileResponse
    from pathlib import Path

    # Get the file record
    file_record = db.query(DataFile).filter_by(id=file_id).first()
    if not file_record:
        raise HTTPException(status_code=404, detail="File not found")

    # Verify file belongs to this project
    session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
    if not session or session.project_id != project_id:
        raise HTTPException(status_code=403, detail="File does not belong to this project")

    # Build full file path
    file_path = Path("data") / file_record.file_path

    if not file_path.exists():
        raise HTTPException(status_code=404, detail="File not found on disk")

    # Extract filename for download
    filename = file_path.name

    return FileResponse(
        path=str(file_path),
        filename=filename,
        media_type="application/octet-stream"
    )


@router.get("/{project_id}/sessions/{session_id}/download-all")
async def download_session_files(
    project_id: str,
    session_id: str,
    db: Session = Depends(get_db),
):
    """
    Download all files from a session as a single zip archive.
    """
    from backend.models import DataFile
    from pathlib import Path
    import zipfile

    # Verify session belongs to this project
    session = db.query(MonitoringSession).filter_by(id=session_id).first()
    if not session:
        raise HTTPException(status_code=404, detail="Session not found")
    if session.project_id != project_id:
        raise HTTPException(status_code=403, detail="Session does not belong to this project")

    # Get all files for this session
    files = db.query(DataFile).filter_by(session_id=session_id).all()
    if not files:
        raise HTTPException(status_code=404, detail="No files found in this session")

    # Create zip in memory
    zip_buffer = io.BytesIO()

    # Get session info for folder naming
    session_date = session.started_at.strftime('%Y-%m-%d_%H%M') if session.started_at else 'unknown'

    # Get unit and location for naming
    unit = db.query(RosterUnit).filter_by(id=session.unit_id).first() if session.unit_id else None
    location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None

    unit_name = unit.id if unit else "unknown_unit"
    location_name = location.name.replace(" ", "_") if location else ""

    # Build folder name for zip contents
    folder_name = f"{session_date}_{unit_name}"
    if location_name:
        folder_name += f"_{location_name}"

    with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
        for file_record in files:
            file_path = Path("data") / file_record.file_path
            if file_path.exists():
                # Add file to zip with folder structure
                arcname = f"{folder_name}/{file_path.name}"
                zip_file.write(file_path, arcname)

    zip_buffer.seek(0)

    # Generate filename for the zip
    zip_filename = f"{folder_name}.zip"

    return StreamingResponse(
        zip_buffer,
        media_type="application/zip",
        headers={"Content-Disposition": f"attachment; filename={zip_filename}"}
    )


@router.delete("/{project_id}/files/{file_id}")
async def delete_project_file(
    project_id: str,
    file_id: str,
    db: Session = Depends(get_db),
):
    """
    Delete a single data file from a project.
    Removes both the database record and the file on disk.
    """
    from backend.models import DataFile
    from pathlib import Path

    # Get the file record
    file_record = db.query(DataFile).filter_by(id=file_id).first()
    if not file_record:
        raise HTTPException(status_code=404, detail="File not found")

    # Verify file belongs to this project
    session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
    if not session or session.project_id != project_id:
        raise HTTPException(status_code=403, detail="File does not belong to this project")

    # Delete file from disk if it exists
    file_path = Path("data") / file_record.file_path
    if file_path.exists():
        file_path.unlink()

    # Delete database record
    db.delete(file_record)
    db.commit()

    return JSONResponse({"status": "success", "message": "File deleted"})


@router.delete("/{project_id}/sessions/{session_id}")
async def delete_session(
    project_id: str,
    session_id: str,
    db: Session = Depends(get_db),
):
    """
    Delete an entire session and all its files.
    Removes database records and files on disk.
    """
    from backend.models import DataFile
    from pathlib import Path

    # Verify session belongs to this project
    session = db.query(MonitoringSession).filter_by(id=session_id).first()
    if not session:
        raise HTTPException(status_code=404, detail="Session not found")
    if session.project_id != project_id:
        raise HTTPException(status_code=403, detail="Session does not belong to this project")

    # Get all files for this session
    files = db.query(DataFile).filter_by(session_id=session_id).all()

    # Delete files from disk
    deleted_count = 0
    for file_record in files:
        file_path = Path("data") / file_record.file_path
        if file_path.exists():
            file_path.unlink()
            deleted_count += 1
        # Delete database record
        db.delete(file_record)

    # Delete the session record
    db.delete(session)
    db.commit()

    return JSONResponse({
        "status": "success",
        "message": f"Session and {deleted_count} file(s) deleted"
    })


# full_24h = a single continuous 24-hour period (day + night). Operator-set
# only; never auto-derived. In reports its rows are split across
# Daytime/Evening/Nighttime by hour rather than filtered to one window.
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night", "full_24h"}


def _derive_period_type(dt: datetime) -> str:
    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:
    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", "full_24h": "24-Hour"}.get(period_type, "")
    parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p]
    return " — ".join(parts)


@router.patch("/{project_id}/sessions/{session_id}")
async def patch_session(
    project_id: str,
    session_id: str,
    data: dict,
    db: Session = Depends(get_db),
):
    """Update session fields: started_at, stopped_at, session_label, period_type."""
    session = db.query(MonitoringSession).filter_by(id=session_id).first()
    if not session:
        raise HTTPException(status_code=404, detail="Session not found")
    if session.project_id != project_id:
        raise HTTPException(status_code=403, detail="Session does not belong to this project")

    times_changed = False

    if "started_at" in data and data["started_at"]:
        try:
            local_dt = datetime.fromisoformat(data["started_at"])
            session.started_at = local_to_utc(local_dt)
            times_changed = True
        except ValueError:
            raise HTTPException(status_code=400, detail="Invalid started_at datetime format")

    if "stopped_at" in data:
        if data["stopped_at"]:
            try:
                local_dt = datetime.fromisoformat(data["stopped_at"])
                session.stopped_at = local_to_utc(local_dt)
                times_changed = True
            except ValueError:
                raise HTTPException(status_code=400, detail="Invalid stopped_at datetime format")
        else:
            session.stopped_at = None
            times_changed = True

    if times_changed and session.started_at and session.stopped_at:
        delta = session.stopped_at - session.started_at
        session.duration_seconds = max(0, int(delta.total_seconds()))
    elif times_changed and not session.stopped_at:
        session.duration_seconds = None

    # Re-derive period_type and session_label from new started_at unless explicitly provided
    if times_changed and session.started_at and "period_type" not in data:
        local_start = utc_to_local(session.started_at)
        session.period_type = _derive_period_type(local_start)

    if times_changed and session.started_at and "session_label" not in data:
        from backend.models import MonitoringLocation
        location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
        location_name = location.name if location else ""
        local_start = utc_to_local(session.started_at)
        session.session_label = _build_session_label(local_start, location_name, session.period_type or "")

    if "session_label" in data:
        session.session_label = str(data["session_label"]).strip() or None
    if "period_type" in data:
        pt = data["period_type"]
        if pt and pt not in VALID_PERIOD_TYPES:
            raise HTTPException(status_code=400, detail=f"Invalid period_type. Must be one of: {', '.join(sorted(VALID_PERIOD_TYPES))}")
        session.period_type = pt or None

    # Configurable period window (0–23 integers; null = no filter)
    for field in ("period_start_hour", "period_end_hour"):
        if field in data:
            val = data[field]
            if val is None or val == "":
                setattr(session, field, None)
            else:
                try:
                    h = int(val)
                    if not (0 <= h <= 23):
                        raise ValueError
                    setattr(session, field, h)
                except (ValueError, TypeError):
                    raise HTTPException(status_code=400, detail=f"{field} must be an integer 0–23 or null")

    if "report_date" in data:
        val = data["report_date"]
        if val is None or val == "":
            session.report_date = None
        else:
            try:
                from datetime import date as _date
                session.report_date = _date.fromisoformat(str(val))
            except ValueError:
                raise HTTPException(status_code=400, detail="Invalid report_date format. Use YYYY-MM-DD.")

    db.commit()
    return JSONResponse({
        "status": "success",
        "session_label": session.session_label,
        "period_type": session.period_type,
        "period_start_hour": session.period_start_hour,
        "period_end_hour": session.period_end_hour,
        "report_date": session.report_date.isoformat() if session.report_date else None,
    })


@router.get("/{project_id}/sessions/{session_id}/detail", response_class=HTMLResponse)
async def view_session_detail(
    request: Request,
    project_id: str,
    session_id: str,
    db: Session = Depends(get_db),
):
    """
    Session detail page: shows files, editable session info, data preview, and report actions.
    """
    from backend.models import DataFile
    from pathlib import Path

    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")
    _require_module(project, "sound_monitoring", db)

    session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first()
    if not session:
        raise HTTPException(status_code=404, detail="Session not found")

    location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
    unit = db.query(RosterUnit).filter_by(id=session.unit_id).first() if session.unit_id else None

    # Load all data files for this session
    files = db.query(DataFile).filter_by(session_id=session_id).order_by(DataFile.created_at).all()

    # Compute effective time range string for display
    effective_range = None
    if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
        local_start = utc_to_local(session.started_at)
        start_day = session.report_date if session.report_date else local_start.date()
        sh = session.period_start_hour
        eh = session.period_end_hour
        def _fmt_h(h):
            ampm = "AM" if h < 12 else "PM"
            h12 = h % 12 or 12
            return f"{h12}:00 {ampm}"
        start_str = f"{start_day.month}/{start_day.day} {_fmt_h(sh)}"
        if eh > sh:
            end_day = start_day
        else:
            from datetime import timedelta as _td
            end_day = start_day + _td(days=1)
        end_str = f"{end_day.month}/{end_day.day} {_fmt_h(eh)}"
        effective_range = f"{start_str} → {end_str}"

    # Parse session_metadata if present
    session_meta = {}
    if session.session_metadata:
        try:
            session_meta = json.loads(session.session_metadata)
        except Exception:
            pass

    return templates.TemplateResponse("session_detail.html", {
        "request": request,
        "project": project,
        "project_id": project_id,
        "session": session,
        "location": location,
        "unit": unit,
        "files": files,
        "effective_range": effective_range,
        "session_meta": session_meta,
        "report_date": session.report_date.isoformat() if session.report_date else "",
    })


@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
async def view_rnd_file(
    request: Request,
    project_id: str,
    file_id: str,
    db: Session = Depends(get_db),
):
    """
    View an RND (sound level meter measurement) file.
    Returns a dedicated page with data table and charts.
    """
    from backend.models import DataFile
    from pathlib import Path

    # Get the file record
    file_record = db.query(DataFile).filter_by(id=file_id).first()
    if not file_record:
        raise HTTPException(status_code=404, detail="File not found")

    # Verify file belongs to this project
    session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
    if not session or session.project_id != project_id:
        raise HTTPException(status_code=403, detail="File does not belong to this project")

    # Build full file path
    file_path = Path("data") / file_record.file_path

    if not file_path.exists():
        raise HTTPException(status_code=404, detail="File not found on disk")

    # Get project info
    project = db.query(Project).filter_by(id=project_id).first()
    _require_module(project, "sound_monitoring", db)

    # Get location info if available
    location = None
    if session.location_id:
        location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()

    # Get unit info if available
    unit = None
    if session.unit_id:
        unit = db.query(RosterUnit).filter_by(id=session.unit_id).first()

    # Parse file metadata
    metadata = {}
    if file_record.file_metadata:
        try:
            metadata = json.loads(file_record.file_metadata)
        except json.JSONDecodeError:
            pass

    return templates.TemplateResponse("rnd_viewer.html", {
        "request": request,
        "project": project,
        "project_id": project_id,
        "file": file_record,
        "file_id": file_id,
        "session": session,
        "location": location,
        "unit": unit,
        "metadata": metadata,
        "filename": file_path.name,
        "is_leq": _is_leq_file(str(file_record.file_path), _peek_rnd_headers(file_path)),
        "period_start_hour": session.period_start_hour,
        "period_end_hour": session.period_end_hour,
    })


@router.get("/{project_id}/files/{file_id}/rnd-data")
async def get_rnd_data(
    project_id: str,
    file_id: str,
    db: Session = Depends(get_db),
):
    """
    Get parsed RND file data as JSON.
    Returns the measurement data for charts and tables.
    Sound Monitoring projects only.
    """
    from backend.models import DataFile
    from pathlib import Path
    import csv
    import io

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

    # Get the file record
    file_record = db.query(DataFile).filter_by(id=file_id).first()
    if not file_record:
        raise HTTPException(status_code=404, detail="File not found")

    # Verify file belongs to this project
    session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
    if not session or session.project_id != project_id:
        raise HTTPException(status_code=403, detail="File does not belong to this project")

    # Build full file path
    file_path = Path("data") / file_record.file_path

    if not file_path.exists():
        raise HTTPException(status_code=404, detail="File not found on disk")

    # Read and parse the RND file
    try:
        with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
            content = f.read()

        # Parse as CSV
        reader = csv.DictReader(io.StringIO(content))
        rows = []
        headers = []

        for row in reader:
            if not headers:
                headers = list(row.keys())
            # Clean up values - strip whitespace and handle special values
            cleaned_row = {}
            for key, value in row.items():
                if key:  # Skip empty keys
                    cleaned_key = key.strip()
                    cleaned_value = value.strip() if value else ''
                    # Convert numeric values
                    if cleaned_value and cleaned_value not in ['-.-', '-', '']:
                        try:
                            cleaned_value = float(cleaned_value)
                        except ValueError:
                            pass
                    elif cleaned_value in ['-.-', '-']:
                        cleaned_value = None
                    cleaned_row[cleaned_key] = cleaned_value
            rows.append(cleaned_row)

        # Normalise AU2-format columns to NL-43 names
        rows, _was_au2 = _normalize_rnd_rows(rows)
        if _was_au2:
            headers = list(rows[0].keys()) if rows else headers

        # Detect file type (Leq vs Lp) based on columns
        file_type = 'unknown'
        if headers:
            header_str = ','.join(headers).lower()
            if 'leq(main)' in header_str or 'laeq' in header_str:
                file_type = 'leq'  # Time-averaged data
            elif 'lp(main)' in header_str or 'lp (main)' in header_str:
                file_type = 'lp'  # Instantaneous data

        # Get summary statistics
        summary = {
            "total_rows": len(rows),
            "file_type": file_type,
            "headers": [h.strip() for h in headers if h.strip()],
        }

        # Calculate min/max/avg for key metrics if available
        metrics_to_summarize = ['Leq(Main)', 'Lmax(Main)', 'Lmin(Main)', 'Lpeak(Main)', 'Lp(Main)']
        for metric in metrics_to_summarize:
            values = [row.get(metric) for row in rows if isinstance(row.get(metric), (int, float))]
            if values:
                summary[f"{metric}_min"] = min(values)
                summary[f"{metric}_max"] = max(values)
                summary[f"{metric}_avg"] = sum(values) / len(values)

        # Get time range
        if rows:
            first_time = rows[0].get('Start Time', '')
            last_time = rows[-1].get('Start Time', '')
            summary['time_start'] = first_time
            summary['time_end'] = last_time

        return {
            "success": True,
            "summary": summary,
            "headers": summary["headers"],
            "data": rows,
            "period_start_hour": session.period_start_hour,
            "period_end_hour": session.period_end_hour,
        }

    except Exception as e:
        logger.error(f"Error parsing RND file: {e}")
        raise HTTPException(status_code=500, detail=f"Error parsing file: {str(e)}")


@router.get("/{project_id}/files/{file_id}/generate-report")
async def generate_excel_report(
    project_id: str,
    file_id: str,
    report_title: str = Query("Background Noise Study", description="Title for the report"),
    location_name: str = Query("", description="Location name (e.g., 'NRL 1 - West Side')"),
    project_name: str = Query("", description="Project name override"),
    client_name: str = Query("", description="Client name for report header"),
    start_time: str = Query("", description="Filter start time (HH:MM format, e.g., '19:00')"),
    end_time: str = Query("", description="Filter end time (HH:MM format, e.g., '07:00')"),
    start_date: str = Query("", description="Filter start date (YYYY-MM-DD format)"),
    end_date: str = Query("", description="Filter end date (YYYY-MM-DD format)"),
    db: Session = Depends(get_db),
):
    """
    Generate an Excel report from an RND file.

    Creates a formatted Excel workbook with:
    - Title and location headers
    - Data table (Test #, Date, Time, LAmax, LA01, LA10, Comments)
    - Line chart visualization
    - Time period summary statistics

    Time filtering:
    - start_time/end_time: Filter to time window (handles overnight like 19:00-07:00)
    - start_date/end_date: Filter to date range

    Column mapping from RND to Report:
    - Lmax(Main) -> LAmax (dBA)
    - LN1(Main) -> LA01 (dBA)  [L1 percentile]
    - LN2(Main) -> LA10 (dBA)  [L10 percentile]
    """
    from backend.models import DataFile
    from pathlib import Path
    import csv

    try:
        import openpyxl
        from openpyxl.chart import LineChart, Reference
        from openpyxl.chart.label import DataLabelList
        from openpyxl.chart.shapes import GraphicalProperties
        from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
        from openpyxl.utils import get_column_letter
    except ImportError:
        raise HTTPException(
            status_code=500,
            detail="openpyxl is not installed. Run: pip install openpyxl"
        )

    # Get the file record
    file_record = db.query(DataFile).filter_by(id=file_id).first()
    if not file_record:
        raise HTTPException(status_code=404, detail="File not found")

    # Verify file belongs to this project
    session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
    if not session or session.project_id != project_id:
        raise HTTPException(status_code=403, detail="File does not belong to this project")

    # Get related data for report context
    project = db.query(Project).filter_by(id=project_id).first()
    _require_module(project, "sound_monitoring", db)
    location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None

    # Build full file path
    file_path = Path("data") / file_record.file_path
    if not file_path.exists():
        raise HTTPException(status_code=404, detail="File not found on disk")

    # Read and parse the RND file
    try:
        with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
            content = f.read()

        reader = csv.DictReader(io.StringIO(content))
        rnd_rows = []
        for row in reader:
            cleaned_row = {}
            for key, value in row.items():
                if key:
                    cleaned_key = key.strip()
                    cleaned_value = value.strip() if value else ''
                    if cleaned_value and cleaned_value not in ['-.-', '-', '']:
                        try:
                            cleaned_value = float(cleaned_value)
                        except ValueError:
                            pass
                    elif cleaned_value in ['-.-', '-']:
                        cleaned_value = None
                    cleaned_row[cleaned_key] = cleaned_value
            rnd_rows.append(cleaned_row)

        if not rnd_rows:
            raise HTTPException(status_code=400, detail="No data found in RND file")

        # Normalise AU2-format columns to NL-43 names
        rnd_rows, _ = _normalize_rnd_rows(rnd_rows)

        # Validate this is a Leq file — Lp files lack the LN percentile data
        if not _is_leq_file(file_record.file_path, rnd_rows):
            raise HTTPException(
                status_code=400,
                detail="Reports can only be generated from Leq files (15-minute averaged data). This appears to be an Lp (instantaneous) file."
            )

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Error reading RND file: {e}")
        raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}")

    # Apply time and date filtering
    def filter_rows_by_time(rows, filter_start_time, filter_end_time, filter_start_date, filter_end_date):
        """Filter rows by time window and date range."""
        if not filter_start_time and not filter_end_time and not filter_start_date and not filter_end_date:
            return rows

        filtered = []

        # Parse time filters
        start_hour = start_minute = end_hour = end_minute = None
        if filter_start_time:
            try:
                parts = filter_start_time.split(':')
                start_hour = int(parts[0])
                start_minute = int(parts[1]) if len(parts) > 1 else 0
            except (ValueError, IndexError):
                pass

        if filter_end_time:
            try:
                parts = filter_end_time.split(':')
                end_hour = int(parts[0])
                end_minute = int(parts[1]) if len(parts) > 1 else 0
            except (ValueError, IndexError):
                pass

        # Parse date filters
        start_dt = end_dt = None
        if filter_start_date:
            try:
                start_dt = datetime.strptime(filter_start_date, '%Y-%m-%d').date()
            except ValueError:
                pass
        if filter_end_date:
            try:
                end_dt = datetime.strptime(filter_end_date, '%Y-%m-%d').date()
            except ValueError:
                pass

        for row in rows:
            start_time_str = row.get('Start Time', '')
            if not start_time_str:
                continue

            try:
                dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
                row_date = dt.date()
                row_hour = dt.hour
                row_minute = dt.minute

                # Date filtering
                if start_dt and row_date < start_dt:
                    continue
                if end_dt and row_date > end_dt:
                    continue

                # Time filtering (handle overnight ranges like 19:00-07:00)
                if start_hour is not None and end_hour is not None:
                    row_time_minutes = row_hour * 60 + row_minute
                    start_time_minutes = start_hour * 60 + start_minute
                    end_time_minutes = end_hour * 60 + end_minute

                    if start_time_minutes > end_time_minutes:
                        # Overnight range (e.g., 19:00-07:00)
                        if not (row_time_minutes >= start_time_minutes or row_time_minutes < end_time_minutes):
                            continue
                    else:
                        # Same day range (e.g., 07:00-19:00)
                        if not (start_time_minutes <= row_time_minutes < end_time_minutes):
                            continue

                filtered.append(row)
            except ValueError:
                # If we can't parse the time, include the row anyway
                filtered.append(row)

        return filtered

    # Apply filters
    original_count = len(rnd_rows)
    rnd_rows = filter_rows_by_time(rnd_rows, start_time, end_time, start_date, end_date)

    if not rnd_rows:
        time_filter_desc = ""
        if start_time and end_time:
            time_filter_desc = f" between {start_time} and {end_time}"
        if start_date or end_date:
            time_filter_desc += f" from {start_date or 'start'} to {end_date or 'end'}"
        raise HTTPException(
            status_code=400,
            detail=f"No data found after applying filters{time_filter_desc}. Original file had {original_count} rows."
        )

    # Create Excel workbook
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = "Sound Level Data"

    # --- Styles ---
    f_title = Font(name='Arial', bold=True, size=12)
    f_data  = Font(name='Arial', size=10)
    f_bold  = Font(name='Arial', bold=True, size=10)

    thin = Side(style='thin')
    dbl  = Side(style='double')

    # Header row: double top border; leftmost/rightmost cells get double outer edge
    hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin)
    hdr_left  = Border(left=dbl,  right=thin, top=dbl, bottom=thin)
    hdr_right = Border(left=thin, right=dbl,  top=dbl, bottom=thin)
    # Last data row: double bottom border
    last_inner = Border(left=thin, right=thin, top=thin, bottom=dbl)
    last_left  = Border(left=dbl,  right=thin, top=thin, bottom=dbl)
    last_right = Border(left=thin, right=dbl,  top=thin, bottom=dbl)
    # Normal data rows
    data_inner = Border(left=thin, right=thin, top=thin, bottom=thin)
    data_left  = Border(left=dbl,  right=thin, top=thin, bottom=thin)
    data_right = Border(left=thin, right=dbl,  top=thin, bottom=thin)

    hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
    center = Alignment(horizontal='center', vertical='center', wrap_text=True)
    left   = Alignment(horizontal='left',   vertical='center')
    right  = Alignment(horizontal='right',  vertical='center')

    # Column widths from Soundstudyexample.xlsx NRL_1 (sheet2)
    # A      B      C     D      E      F      G     H     I      J      K     L      M    N     O     P
    for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]):
        ws.column_dimensions[get_column_letter(col_i)].width = col_w

    # --- Header rows 1-6 ---
    final_project_name = project_name if project_name else (project.name if project else "")
    final_location = location_name if location_name else (location.name if location else "")
    final_title = f"{report_title} - {final_project_name}" if final_project_name else report_title

    ws.merge_cells('A1:G1')
    ws['A1'] = final_title
    ws['A1'].font = f_title; ws['A1'].alignment = center
    ws.row_dimensions[1].height = 15.75

    ws.row_dimensions[2].height = 15

    ws.merge_cells('A3:G3')
    ws['A3'] = final_location
    ws['A3'].font = f_title; ws['A3'].alignment = center
    ws.row_dimensions[3].height = 15.75

    ws.row_dimensions[4].height = 15

    date_range_str = ''
    if start_date or end_date:
        date_range_str = f"{start_date or ''} to {end_date or ''}"
    elif start_time and end_time:
        date_range_str = f"{start_time} - {end_time}"
    ws.merge_cells('A5:G5')
    ws['A5'] = date_range_str
    ws['A5'].font = f_data; ws['A5'].alignment = center
    ws.row_dimensions[5].height = 15.75

    hdr_labels = ['Interval #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
    for col, label in enumerate(hdr_labels, 1):
        cell = ws.cell(row=6, column=col, value=label)
        cell.font = f_bold; cell.fill = hdr_fill; cell.alignment = center
        cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner)
    ws.row_dimensions[6].height = 39

    # --- Data rows starting at row 7 ---
    data_start_row = 7
    parsed_rows = []
    for idx, row in enumerate(rnd_rows, 1):
        dr = data_start_row + idx - 1
        is_last = (idx == len(rnd_rows))
        b_left  = last_left  if is_last else data_left
        b_inner = last_inner if is_last else data_inner
        b_right = last_right if is_last else data_right

        c = ws.cell(row=dr, column=1, value=idx)
        c.font = f_data; c.alignment = center; c.border = b_left

        start_time_str = row.get('Start Time', '')
        row_dt = None
        if start_time_str:
            try:
                row_dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
                c2 = ws.cell(row=dr, column=2, value=row_dt.strftime('%m/%d/%y'))
                c3 = ws.cell(row=dr, column=3, value=row_dt.strftime('%H:%M'))
            except ValueError:
                c2 = ws.cell(row=dr, column=2, value=start_time_str)
                c3 = ws.cell(row=dr, column=3, value='')
        else:
            c2 = ws.cell(row=dr, column=2, value='')
            c3 = ws.cell(row=dr, column=3, value='')
        c2.font = f_data; c2.alignment = center; c2.border = b_inner
        c3.font = f_data; c3.alignment = center; c3.border = b_inner

        lmax = row.get('Lmax(Main)')
        ln1  = row.get('LN1(Main)')
        ln2  = row.get('LN2(Main)')
        for col_idx, val in [(4, lmax), (5, ln1), (6, ln2)]:
            c = ws.cell(row=dr, column=col_idx, value=val if isinstance(val, (int, float)) else '')
            c.font = f_data; c.alignment = center; c.border = b_inner

        c = ws.cell(row=dr, column=7, value='')
        c.font = f_data; c.alignment = left; c.border = b_right
        ws.row_dimensions[dr].height = 15

        if row_dt and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)):
            parsed_rows.append((row_dt, lmax, ln1, ln2))

    data_end_row = data_start_row + len(rnd_rows) - 1

    # --- Chart anchored at H4, spanning H4:P29 ---
    chart = LineChart()
    chart.title = f"{final_location} - {final_title}" if final_location else final_title
    chart.style = 2
    chart.y_axis.title = "Sound Level (dBA)"
    chart.x_axis.title = "Time Period (15 Minute Intervals)"
    # 9 cols × 0.70" = 6.3" wide; H4:P29 = 25 rows at ~15pt ≈ 16.5cm tall
    chart.height = 12.7
    chart.width = 15.7

    data_ref = Reference(ws, min_col=4, min_row=6, max_col=6, max_row=data_end_row)
    categories = Reference(ws, min_col=3, min_row=data_start_row, max_row=data_end_row)
    chart.add_data(data_ref, titles_from_data=True)
    chart.set_categories(categories)

    if len(chart.series) >= 3:
        chart.series[0].graphicalProperties.line.solidFill = "C00000"
        chart.series[0].graphicalProperties.line.width = 15875
        chart.series[1].graphicalProperties.line.solidFill = "00B050"
        chart.series[1].graphicalProperties.line.width = 19050
        chart.series[2].graphicalProperties.line.solidFill = "0070C0"
        chart.series[2].graphicalProperties.line.width = 19050

    _plot_border = GraphicalProperties()
    _plot_border.ln.solidFill = "000000"
    _plot_border.ln.w = 12700
    chart.plot_area.spPr = _plot_border
    ws.add_chart(chart, "I4")

    # --- Stats table: note at I28-I29, headers at I31, data rows 32-34 ---
    note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
    note1.font = f_data; note1.alignment = left
    ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14)
    note2 = ws.cell(row=29, column=9, value="for each specified range of time intervals.")
    note2.font = f_data; note2.alignment = left
    ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14)

    # Table header row 31
    med = Side(style='medium')
    tbl_top_left   = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin'))
    tbl_top_mid    = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin'))
    tbl_top_right  = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin'))
    tbl_mid_left   = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_mid_mid    = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_mid_right  = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_bot_left   = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med)
    tbl_bot_mid    = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med)
    tbl_bot_right  = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med)
    # No vertical divider between value and dBA cells
    tbl_top_val    = Border(left=Side(style='thin'), right=Side(), top=med, bottom=Side(style='thin'))
    tbl_top_unit   = Border(left=Side(),  right=Side(style='thin'), top=med, bottom=Side(style='thin'))
    tbl_top_rval   = Border(left=Side(style='thin'), right=Side(), top=med, bottom=Side(style='thin'))
    tbl_top_runit  = Border(left=Side(),  right=med, top=med, bottom=Side(style='thin'))
    tbl_mid_val    = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_mid_unit   = Border(left=Side(),  right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_mid_rval   = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_mid_runit  = Border(left=Side(),  right=med, top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_bot_val    = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=med)
    tbl_bot_unit   = Border(left=Side(),  right=Side(style='thin'), top=Side(style='thin'), bottom=med)
    tbl_bot_rval   = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=med)
    tbl_bot_runit  = Border(left=Side(),  right=med, top=Side(style='thin'), bottom=med)

    hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")

    # Header row: blank | Evening | Nighttime
    c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold
    c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)")
    c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
    c.border = tbl_top_mid; c.fill = hdr_fill_tbl
    ws.merge_cells(start_row=31, start_column=10, end_row=31, end_column=11)
    c = ws.cell(row=31, column=12, value="Nighttime (10PM to 7AM)")
    c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
    c.border = tbl_top_right; c.fill = hdr_fill_tbl
    ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13)
    ws.row_dimensions[31].height = 15

    evening   = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows if 19 <= dt.hour < 22]
    nighttime = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows if dt.hour >= 22 or dt.hour < 7]

    def _avg(vals): return round(sum(vals) / len(vals), 1) if vals else None
    def _max(vals): return round(max(vals), 1) if vals else None

    def write_stat(row_num, label, eve_val, night_val, is_last=False):
        bl  = tbl_bot_left  if is_last else tbl_mid_left
        bm  = tbl_bot_mid   if is_last else tbl_mid_mid
        br  = tbl_bot_right if is_last else tbl_mid_right
        lbl = ws.cell(row=row_num, column=9, value=label)
        lbl.font = f_data; lbl.border = bl
        lbl.alignment = Alignment(horizontal='left', vertical='center')
        ev_str = f"{eve_val} dBA" if eve_val is not None else ""
        ev = ws.cell(row=row_num, column=10, value=ev_str)
        ev.font = f_bold; ev.border = bm
        ev.alignment = Alignment(horizontal='center', vertical='center')
        ws.merge_cells(start_row=row_num, start_column=10, end_row=row_num, end_column=11)
        ni_str = f"{night_val} dBA" if night_val is not None else ""
        ni = ws.cell(row=row_num, column=12, value=ni_str)
        ni.font = f_bold; ni.border = br
        ni.alignment = Alignment(horizontal='center', vertical='center')
        ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13)

    write_stat(32, "LAmax",       _max([v[0] for v in evening]),  _max([v[0] for v in nighttime]))
    write_stat(33, "LA01 Average",_avg([v[1] for v in evening]),  _avg([v[1] for v in nighttime]))
    write_stat(34, "LA10 Average",_avg([v[2] for v in evening]),  _avg([v[2] for v in nighttime]), is_last=True)

    # --- Page setup: portrait, letter, template margins ---
    from openpyxl.worksheet.properties import PageSetupProperties
    ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False)
    ws.page_setup.orientation = 'portrait'
    ws.page_setup.paperSize = 1  # Letter
    ws.page_margins.left   = 0.75
    ws.page_margins.right  = 0.75
    ws.page_margins.top    = 1.0
    ws.page_margins.bottom = 1.0
    ws.page_margins.header = 0.5
    ws.page_margins.footer = 0.5

    # Save to buffer
    output = io.BytesIO()
    wb.save(output)
    output.seek(0)

    # Generate filename
    filename = file_record.file_path.split('/')[-1].replace('.rnd', '')
    if location:
        filename = f"{location.name}_{filename}"
    filename = f"{filename}_report.xlsx"
    # Clean filename
    filename = "".join(c for c in filename if c.isalnum() or c in ('_', '-', '.')).rstrip()

    return StreamingResponse(
        output,
        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'}
    )


@router.get("/{project_id}/files/{file_id}/preview-report")
async def preview_report_data(
    request: Request,
    project_id: str,
    file_id: str,
    report_title: str = Query("Background Noise Study", description="Title for the report"),
    location_name: str = Query("", description="Location name"),
    project_name: str = Query("", description="Project name override"),
    client_name: str = Query("", description="Client name"),
    start_time: str = Query("", description="Filter start time (HH:MM format)"),
    end_time: str = Query("", description="Filter end time (HH:MM format)"),
    start_date: str = Query("", description="Filter start date (YYYY-MM-DD format)"),
    end_date: str = Query("", description="Filter end date (YYYY-MM-DD format)"),
    db: Session = Depends(get_db),
):
    """
    Preview report data for editing in jspreadsheet.
    Returns an HTML page with the spreadsheet editor.
    """
    from backend.models import DataFile, ReportTemplate
    from pathlib import Path
    import csv

    # Get the file record
    file_record = db.query(DataFile).filter_by(id=file_id).first()
    if not file_record:
        raise HTTPException(status_code=404, detail="File not found")

    # Verify file belongs to this project
    session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
    if not session or session.project_id != project_id:
        raise HTTPException(status_code=403, detail="File does not belong to this project")

    # Get related data for report context
    project = db.query(Project).filter_by(id=project_id).first()
    _require_module(project, "sound_monitoring", db)
    location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None

    # Build full file path
    file_path = Path("data") / file_record.file_path
    if not file_path.exists():
        raise HTTPException(status_code=404, detail="File not found on disk")

    # Read and parse the RND file
    try:
        with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
            content = f.read()

        reader = csv.DictReader(io.StringIO(content))
        rnd_rows = []
        for row in reader:
            cleaned_row = {}
            for key, value in row.items():
                if key:
                    cleaned_key = key.strip()
                    cleaned_value = value.strip() if value else ''
                    if cleaned_value and cleaned_value not in ['-.-', '-', '']:
                        try:
                            cleaned_value = float(cleaned_value)
                        except ValueError:
                            pass
                    elif cleaned_value in ['-.-', '-']:
                        cleaned_value = None
                    cleaned_row[cleaned_key] = cleaned_value
            rnd_rows.append(cleaned_row)

        if not rnd_rows:
            raise HTTPException(status_code=400, detail="No data found in RND file")

        rnd_rows, _ = _normalize_rnd_rows(rnd_rows)

        if not _is_leq_file(file_record.file_path, rnd_rows):
            raise HTTPException(
                status_code=400,
                detail="Reports can only be generated from Leq files (15-minute averaged data)."
            )

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Error reading RND file: {e}")
        raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}")

    # Apply time and date filtering (same logic as generate-report)
    def filter_rows(rows, filter_start_time, filter_end_time, filter_start_date, filter_end_date):
        if not filter_start_time and not filter_end_time and not filter_start_date and not filter_end_date:
            return rows

        filtered = []
        start_hour = start_minute = end_hour = end_minute = None

        if filter_start_time:
            try:
                parts = filter_start_time.split(':')
                start_hour = int(parts[0])
                start_minute = int(parts[1]) if len(parts) > 1 else 0
            except (ValueError, IndexError):
                pass

        if filter_end_time:
            try:
                parts = filter_end_time.split(':')
                end_hour = int(parts[0])
                end_minute = int(parts[1]) if len(parts) > 1 else 0
            except (ValueError, IndexError):
                pass

        start_dt = end_dt = None
        if filter_start_date:
            try:
                start_dt = datetime.strptime(filter_start_date, '%Y-%m-%d').date()
            except ValueError:
                pass
        if filter_end_date:
            try:
                end_dt = datetime.strptime(filter_end_date, '%Y-%m-%d').date()
            except ValueError:
                pass

        for row in rows:
            start_time_str = row.get('Start Time', '')
            if not start_time_str:
                continue

            try:
                dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
                row_date = dt.date()
                row_hour = dt.hour
                row_minute = dt.minute

                if start_dt and row_date < start_dt:
                    continue
                if end_dt and row_date > end_dt:
                    continue

                if start_hour is not None and end_hour is not None:
                    row_time_minutes = row_hour * 60 + row_minute
                    start_time_minutes = start_hour * 60 + start_minute
                    end_time_minutes = end_hour * 60 + end_minute

                    if start_time_minutes > end_time_minutes:
                        if not (row_time_minutes >= start_time_minutes or row_time_minutes < end_time_minutes):
                            continue
                    else:
                        if not (start_time_minutes <= row_time_minutes < end_time_minutes):
                            continue

                filtered.append(row)
            except ValueError:
                filtered.append(row)

        return filtered

    original_count = len(rnd_rows)
    rnd_rows = filter_rows(rnd_rows, start_time, end_time, start_date, end_date)

    # Convert to spreadsheet data format (array of arrays)
    spreadsheet_data = []
    for idx, row in enumerate(rnd_rows, 1):
        start_time_str = row.get('Start Time', '')
        date_str = ''
        time_str = ''
        if start_time_str:
            try:
                dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
                date_str = dt.strftime('%Y-%m-%d')
                time_str = dt.strftime('%H:%M')
            except ValueError:
                date_str = start_time_str
                time_str = ''

        lmax = row.get('Lmax(Main)', '')
        ln1 = row.get('LN1(Main)', '')
        ln2 = row.get('LN2(Main)', '')

        spreadsheet_data.append([
            idx,  # Test #
            date_str,
            time_str,
            lmax if lmax else '',
            ln1 if ln1 else '',
            ln2 if ln2 else '',
            ''  # Comments
        ])

    # Prepare context data
    final_project_name = project_name if project_name else (project.name if project else "")
    final_location = location_name if location_name else (location.name if location else "")

    # Get templates for the dropdown
    report_templates = db.query(ReportTemplate).all()

    return templates.TemplateResponse("report_preview.html", {
        "request": request,
        "project_id": project_id,
        "file_id": file_id,
        "project": project,
        "location": location,
        "file": file_record,
        "spreadsheet_data": spreadsheet_data,
        "report_title": report_title,
        "project_name": final_project_name,
        "client_name": client_name,
        "location_name": final_location,
        "start_time": start_time,
        "end_time": end_time,
        "start_date": start_date,
        "end_date": end_date,
        "original_count": original_count,
        "filtered_count": len(rnd_rows),
        "templates": report_templates,
    })


@router.post("/{project_id}/files/{file_id}/generate-from-preview")
async def generate_report_from_preview(
    project_id: str,
    file_id: str,
    data: dict,
    db: Session = Depends(get_db),
):
    """
    Generate an Excel report from edited spreadsheet data.
    Accepts the edited data from jspreadsheet and creates the final Excel file.
    """
    from backend.models import DataFile
    from pathlib import Path

    try:
        import openpyxl
        from openpyxl.chart import LineChart, Reference
        from openpyxl.chart.shapes import GraphicalProperties
        from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
        from openpyxl.utils import get_column_letter
    except ImportError:
        raise HTTPException(status_code=500, detail="openpyxl is not installed")

    # Get the file record for filename generation
    file_record = db.query(DataFile).filter_by(id=file_id).first()
    if not file_record:
        raise HTTPException(status_code=404, detail="File not found")

    session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
    if not session or session.project_id != project_id:
        raise HTTPException(status_code=403, detail="File does not belong to this project")

    project = db.query(Project).filter_by(id=project_id).first()
    _require_module(project, "sound_monitoring", db)
    location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None

    # Extract data from request
    spreadsheet_data = data.get('data', [])
    report_title = data.get('report_title', 'Background Noise Study')
    project_name = data.get('project_name', project.name if project else '')
    client_name = data.get('client_name', '')
    location_name = data.get('location_name', location.name if location else '')
    time_filter = data.get('time_filter', '')

    if not spreadsheet_data:
        raise HTTPException(status_code=400, detail="No data provided")

    # Create Excel workbook
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = "Sound Level Data"

    # --- Styles ---
    f_title = Font(name='Arial', bold=True, size=12)
    f_data  = Font(name='Arial', size=10)
    f_bold  = Font(name='Arial', bold=True, size=10)

    thin = Side(style='thin')
    dbl  = Side(style='double')

    hdr_inner  = Border(left=thin, right=thin, top=dbl,  bottom=thin)
    hdr_left   = Border(left=dbl,  right=thin, top=dbl,  bottom=thin)
    hdr_right  = Border(left=thin, right=dbl,  top=dbl,  bottom=thin)
    last_inner = Border(left=thin, right=thin, top=thin, bottom=dbl)
    last_left  = Border(left=dbl,  right=thin, top=thin, bottom=dbl)
    last_right = Border(left=thin, right=dbl,  top=thin, bottom=dbl)
    data_inner = Border(left=thin, right=thin, top=thin, bottom=thin)
    data_left  = Border(left=dbl,  right=thin, top=thin, bottom=thin)
    data_right = Border(left=thin, right=dbl,  top=thin, bottom=thin)

    hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
    center = Alignment(horizontal='center', vertical='center', wrap_text=True)
    left   = Alignment(horizontal='left',   vertical='center')
    right  = Alignment(horizontal='right',  vertical='center')

    # Column widths from Soundstudyexample.xlsx NRL_1 (sheet2)
    # A      B      C     D      E      F      G     H     I      J      K     L      M    N     O     P
    for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]):
        ws.column_dimensions[get_column_letter(col_i)].width = col_w

    # --- Header rows 1-6 ---
    final_title = f"{report_title} - {project_name}" if project_name else report_title

    ws.merge_cells('A1:G1')
    ws['A1'] = final_title
    ws['A1'].font = f_title; ws['A1'].alignment = center
    ws.row_dimensions[1].height = 15.75
    ws.row_dimensions[2].height = 15

    ws.merge_cells('A3:G3')
    ws['A3'] = location_name
    ws['A3'].font = f_title; ws['A3'].alignment = center
    ws.row_dimensions[3].height = 15.75
    ws.row_dimensions[4].height = 15

    ws.merge_cells('A5:G5')
    ws['A5'] = time_filter
    ws['A5'].font = f_data; ws['A5'].alignment = center
    ws.row_dimensions[5].height = 15.75

    hdr_labels = ['Interval #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
    for col, label in enumerate(hdr_labels, 1):
        cell = ws.cell(row=6, column=col, value=label)
        cell.font = f_bold; cell.fill = hdr_fill; cell.alignment = center
        cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner)
    ws.row_dimensions[6].height = 39

    # --- Data rows starting at row 7 ---
    data_start_row = 7
    parsed_rows = []
    for idx, row_data in enumerate(spreadsheet_data, 1):
        dr = data_start_row + idx - 1
        is_last = (idx == len(spreadsheet_data))
        b_left  = last_left  if is_last else data_left
        b_inner = last_inner if is_last else data_inner
        b_right = last_right if is_last else data_right
        col_borders = [b_left] + [b_inner] * 5 + [b_right]
        col_aligns  = [center, center, center, center, center, center, left]
        for col, value in enumerate(row_data, 1):
            cell = ws.cell(row=dr, column=col, value=value if value != '' else None)
            cell.font = f_data
            cell.border = col_borders[col - 1] if col <= 7 else b_inner
            cell.alignment = col_aligns[col - 1] if col <= 7 else left
        ws.row_dimensions[dr].height = 15

        try:
            time_str = row_data[2] if len(row_data) > 2 else ''
            lmax_v = row_data[3] if len(row_data) > 3 else ''
            ln1_v  = row_data[4] if len(row_data) > 4 else ''
            ln2_v  = row_data[5] if len(row_data) > 5 else ''
            if time_str and isinstance(lmax_v, (int, float)):
                try:
                    row_dt = datetime.strptime(time_str, '%H:%M')
                except ValueError:
                    row_dt = datetime.strptime(time_str, '%H:%M:%S')
                parsed_rows.append((row_dt, float(lmax_v), float(ln1_v), float(ln2_v)))
        except (ValueError, TypeError):
            pass

    data_end_row = data_start_row + len(spreadsheet_data) - 1

    # --- Chart anchored at H4, spanning H4:P29 ---
    if spreadsheet_data:
        chart = LineChart()
        chart.title = f"{location_name} - {final_title}" if location_name else final_title
        chart.style = 2
        chart.y_axis.title = "Sound Level (dBA)"
        chart.x_axis.title = "Time Period (15 Minute Intervals)"
        chart.height = 12.7
        chart.width = 15.7
        data_ref = Reference(ws, min_col=4, min_row=6, max_col=6, max_row=data_end_row)
        categories = Reference(ws, min_col=3, min_row=data_start_row, max_row=data_end_row)
        chart.add_data(data_ref, titles_from_data=True)
        chart.set_categories(categories)
        if len(chart.series) >= 3:
            chart.series[0].graphicalProperties.line.solidFill = "C00000"
            chart.series[0].graphicalProperties.line.width = 15875
            chart.series[1].graphicalProperties.line.solidFill = "00B050"
            chart.series[1].graphicalProperties.line.width = 19050
            chart.series[2].graphicalProperties.line.solidFill = "0070C0"
            chart.series[2].graphicalProperties.line.width = 19050
        _plot_border = GraphicalProperties()
        _plot_border.ln.solidFill = "000000"
        _plot_border.ln.w = 12700
        chart.plot_area.spPr = _plot_border
        ws.add_chart(chart, "I4")

    # --- Stats block starting at I28 ---
    # Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
    note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
    note1.font = f_data; note1.alignment = left
    ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14)
    note2 = ws.cell(row=29, column=9, value="for each specified range of time intervals.")
    note2.font = f_data; note2.alignment = left
    ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14)

    # Table header row 31
    med = Side(style='medium')
    tbl_top_left   = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin'))
    tbl_top_mid    = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin'))
    tbl_top_right  = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin'))
    tbl_mid_left   = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_mid_mid    = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_mid_right  = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_bot_left   = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med)
    tbl_bot_mid    = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med)
    tbl_bot_right  = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med)
    # No vertical divider between value and dBA cells
    tbl_top_val    = Border(left=Side(style='thin'), right=Side(), top=med, bottom=Side(style='thin'))
    tbl_top_unit   = Border(left=Side(),  right=Side(style='thin'), top=med, bottom=Side(style='thin'))
    tbl_top_rval   = Border(left=Side(style='thin'), right=Side(), top=med, bottom=Side(style='thin'))
    tbl_top_runit  = Border(left=Side(),  right=med, top=med, bottom=Side(style='thin'))
    tbl_mid_val    = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_mid_unit   = Border(left=Side(),  right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_mid_rval   = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_mid_runit  = Border(left=Side(),  right=med, top=Side(style='thin'), bottom=Side(style='thin'))
    tbl_bot_val    = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=med)
    tbl_bot_unit   = Border(left=Side(),  right=Side(style='thin'), top=Side(style='thin'), bottom=med)
    tbl_bot_rval   = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=med)
    tbl_bot_runit  = Border(left=Side(),  right=med, top=Side(style='thin'), bottom=med)

    hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")

    # Header row: blank | Evening | Nighttime
    c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold
    c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)")
    c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
    c.border = tbl_top_mid; c.fill = hdr_fill_tbl
    ws.merge_cells(start_row=31, start_column=10, end_row=31, end_column=11)
    c = ws.cell(row=31, column=12, value="Nighttime (10PM to 7AM)")
    c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
    c.border = tbl_top_right; c.fill = hdr_fill_tbl
    ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13)
    ws.row_dimensions[31].height = 15

    evening2   = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows if 19 <= dt.hour < 22]
    nighttime2 = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows if dt.hour >= 22 or dt.hour < 7]

    def _avg2(vals): return round(sum(vals) / len(vals), 1) if vals else None
    def _max2(vals): return round(max(vals), 1) if vals else None

    def write_stat2(row_num, label, eve_val, night_val, is_last=False):
        bl  = tbl_bot_left  if is_last else tbl_mid_left
        bm  = tbl_bot_mid   if is_last else tbl_mid_mid
        br  = tbl_bot_right if is_last else tbl_mid_right
        lbl = ws.cell(row=row_num, column=9, value=label)
        lbl.font = f_data; lbl.border = bl
        lbl.alignment = Alignment(horizontal='left', vertical='center')
        ev_str = f"{eve_val} dBA" if eve_val is not None else ""
        ev = ws.cell(row=row_num, column=10, value=ev_str)
        ev.font = f_bold; ev.border = bm
        ev.alignment = Alignment(horizontal='center', vertical='center')
        ws.merge_cells(start_row=row_num, start_column=10, end_row=row_num, end_column=11)
        ni_str = f"{night_val} dBA" if night_val is not None else ""
        ni = ws.cell(row=row_num, column=12, value=ni_str)
        ni.font = f_bold; ni.border = br
        ni.alignment = Alignment(horizontal='center', vertical='center')
        ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13)

    write_stat2(32, "LAmax",       _max2([v[0] for v in evening2]),  _max2([v[0] for v in nighttime2]))
    write_stat2(33, "LA01 Average",_avg2([v[1] for v in evening2]),  _avg2([v[1] for v in nighttime2]))
    write_stat2(34, "LA10 Average",_avg2([v[2] for v in evening2]),  _avg2([v[2] for v in nighttime2]), is_last=True)

    # Page setup: portrait, letter, template margins
    from openpyxl.worksheet.properties import PageSetupProperties
    ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False)
    ws.page_setup.orientation = 'portrait'
    ws.page_setup.paperSize = 1
    ws.page_margins.left   = 0.75
    ws.page_margins.right  = 0.75
    ws.page_margins.top    = 1.0
    ws.page_margins.bottom = 1.0
    ws.page_margins.header = 0.5
    ws.page_margins.footer = 0.5

    # Save to buffer
    output = io.BytesIO()
    wb.save(output)
    output.seek(0)

    # Generate filename
    filename = file_record.file_path.split('/')[-1].replace('.rnd', '')
    if location:
        filename = f"{location.name}_{filename}"
    filename = f"{filename}_report.xlsx"
    filename = "".join(c for c in filename if c.isalnum() or c in ('_', '-', '.')).rstrip()

    return StreamingResponse(
        output,
        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'}
    )


@router.get("/{project_id}/generate-combined-report")
async def generate_combined_excel_report(
    project_id: str,
    report_title: str = Query("Background Noise Study", description="Title for the report"),
    db: Session = Depends(get_db),
):
    """
    Generate a combined Excel report from all RND files in a project.

    Creates a multi-sheet Excel workbook with:
    - One sheet per location/RND file
    - Data tables with LAmax, LA01, LA10
    - Line charts for each location
    - Summary sheet combining all locations

    Column mapping from RND to Report:
    - Lmax(Main) -> LAmax (dBA)
    - LN1(Main) -> LA01 (dBA)  [L1 percentile]
    - LN2(Main) -> LA10 (dBA)  [L10 percentile]
    """
    from backend.models import DataFile
    from pathlib import Path
    import csv

    try:
        import openpyxl
        from openpyxl.chart import LineChart, Reference
        from openpyxl.chart.shapes import GraphicalProperties
        from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
        from openpyxl.utils import get_column_letter
    except ImportError:
        raise HTTPException(
            status_code=500,
            detail="openpyxl is not installed. Run: pip install openpyxl"
        )

    # Get project
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")
    _require_module(project, "sound_monitoring", db)

    # Get all sessions with measurement files
    sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()

    # Collect all Leq RND files grouped by location
    # Only include files with '_Leq_' in the path (15-minute averaged data)
    # Exclude Lp files (instantaneous 100ms readings)
    location_files = {}
    for session in sessions:
        files = db.query(DataFile).filter_by(session_id=session.id).all()
        for file in files:
            if not file.file_path or not file.file_path.lower().endswith('.rnd'):
                continue
            from pathlib import Path as _Path
            abs_path = _Path("data") / file.file_path
            peek = _peek_rnd_headers(abs_path)
            if _is_leq_file(file.file_path, peek):
                location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
                location_name = location.name if location else f"Session {session.id[:8]}"

                if location_name not in location_files:
                    location_files[location_name] = []
                location_files[location_name].append({
                    'file': file,
                    'session': session,
                    'location': location
                })

    if not location_files:
        raise HTTPException(status_code=404, detail="No Leq measurement files found in project. Reports require Leq data (files with '_Leq_' in the name).")

    # --- Shared styles ---
    f_title = Font(name='Arial', bold=True, size=12)
    f_data  = Font(name='Arial', size=10)
    f_bold  = Font(name='Arial', bold=True, size=10)

    thin = Side(style='thin')
    dbl  = Side(style='double')

    hdr_inner  = Border(left=thin, right=thin, top=dbl,  bottom=thin)
    hdr_left   = Border(left=dbl,  right=thin, top=dbl,  bottom=thin)
    hdr_right  = Border(left=thin, right=dbl,  top=dbl,  bottom=thin)
    last_inner = Border(left=thin, right=thin, top=thin, bottom=dbl)
    last_left  = Border(left=dbl,  right=thin, top=thin, bottom=dbl)
    last_right = Border(left=thin, right=dbl,  top=thin, bottom=dbl)
    data_inner = Border(left=thin, right=thin, top=thin, bottom=thin)
    data_left  = Border(left=dbl,  right=thin, top=thin, bottom=thin)
    data_right = Border(left=thin, right=dbl,  top=thin, bottom=thin)

    hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
    center = Alignment(horizontal='center', vertical='center', wrap_text=True)
    left_a = Alignment(horizontal='left',   vertical='center')

    # Create Excel workbook
    wb = openpyxl.Workbook()
    wb.remove(wb.active)

    # Track all data for summary
    all_location_summaries = []

    # Create a sheet for each location
    for location_name, file_list in location_files.items():
        safe_sheet_name = "".join(c for c in location_name if c.isalnum() or c in (' ', '-', '_'))[:31]
        ws = wb.create_sheet(title=safe_sheet_name)

        # Column widths from Soundstudyexample.xlsx NRL_1 (sheet2)
        # A      B      C     D      E      F      G     H     I      J      K     L      M    N     O     P
        for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]):
            ws.column_dimensions[get_column_letter(col_i)].width = col_w

        final_title = f"{report_title} - {project.name}"
        ws.merge_cells('A1:G1')
        ws['A1'] = final_title
        ws['A1'].font = f_title; ws['A1'].alignment = center
        ws.row_dimensions[1].height = 15.75
        ws.row_dimensions[2].height = 15

        ws.merge_cells('A3:G3')
        ws['A3'] = location_name
        ws['A3'].font = f_title; ws['A3'].alignment = center
        ws.row_dimensions[3].height = 15.75
        ws.row_dimensions[4].height = 15
        ws.row_dimensions[5].height = 15.75

        hdr_labels = ['Interval #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
        for col, label in enumerate(hdr_labels, 1):
            cell = ws.cell(row=6, column=col, value=label)
            cell.font = f_bold; cell.fill = hdr_fill; cell.alignment = center
            cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner)
        ws.row_dimensions[6].height = 39

        # Combine data from all files for this location
        all_rnd_rows = []
        for file_info in file_list:
            file = file_info['file']
            file_path = Path("data") / file.file_path

            if not file_path.exists():
                continue

            try:
                with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
                    content_f = f.read()

                reader = csv.DictReader(io.StringIO(content_f))
                for row in reader:
                    cleaned_row = {}
                    for key, value in row.items():
                        if key:
                            cleaned_key = key.strip()
                            cleaned_value = value.strip() if value else ''
                            if cleaned_value and cleaned_value not in ['-.-', '-', '']:
                                try:
                                    cleaned_value = float(cleaned_value)
                                except ValueError:
                                    pass
                            elif cleaned_value in ['-.-', '-']:
                                cleaned_value = None
                            cleaned_row[cleaned_key] = cleaned_value
                    all_rnd_rows.append(cleaned_row)
            except Exception as e:
                logger.warning(f"Error reading file {file.file_path}: {e}")
                continue

        if not all_rnd_rows:
            continue

        all_rnd_rows.sort(key=lambda r: r.get('Start Time', ''))

        data_start_row = 7
        parsed_rows_c = []
        for idx, row in enumerate(all_rnd_rows, 1):
            dr = data_start_row + idx - 1
            is_last = (idx == len(all_rnd_rows))
            b_left  = last_left  if is_last else data_left
            b_inner = last_inner if is_last else data_inner
            b_right = last_right if is_last else data_right

            c = ws.cell(row=dr, column=1, value=idx)
            c.font = f_data; c.alignment = center; c.border = b_left

            start_time_str = row.get('Start Time', '')
            row_dt = None
            if start_time_str:
                try:
                    row_dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
                    c2 = ws.cell(row=dr, column=2, value=row_dt.strftime('%m/%d/%y'))
                    c3 = ws.cell(row=dr, column=3, value=row_dt.strftime('%H:%M'))
                except ValueError:
                    c2 = ws.cell(row=dr, column=2, value=start_time_str)
                    c3 = ws.cell(row=dr, column=3, value='')
            else:
                c2 = ws.cell(row=dr, column=2, value='')
                c3 = ws.cell(row=dr, column=3, value='')
            c2.font = f_data; c2.alignment = center; c2.border = b_inner
            c3.font = f_data; c3.alignment = center; c3.border = b_inner

            lmax = row.get('Lmax(Main)')
            ln1  = row.get('LN1(Main)')
            ln2  = row.get('LN2(Main)')
            for col_idx, val in [(4, lmax), (5, ln1), (6, ln2)]:
                c = ws.cell(row=dr, column=col_idx, value=val if isinstance(val, (int, float)) else '')
                c.font = f_data; c.alignment = center; c.border = b_inner

            c = ws.cell(row=dr, column=7, value='')
            c.font = f_data; c.alignment = left_a; c.border = b_right
            ws.row_dimensions[dr].height = 15

            if row_dt and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)):
                parsed_rows_c.append((row_dt, lmax, ln1, ln2))

        data_end_row = data_start_row + len(all_rnd_rows) - 1

        chart = LineChart()
        chart.title = f"{location_name} - {final_title}"
        chart.style = 2
        chart.y_axis.title = "Sound Level (dBA)"
        chart.x_axis.title = "Time Period (15 Minute Intervals)"
        chart.height = 12.7
        chart.width = 15.7

        data_ref = Reference(ws, min_col=4, min_row=6, max_col=6, max_row=data_end_row)
        categories = Reference(ws, min_col=3, min_row=data_start_row, max_row=data_end_row)
        chart.add_data(data_ref, titles_from_data=True)
        chart.set_categories(categories)

        if len(chart.series) >= 3:
            chart.series[0].graphicalProperties.line.solidFill = "C00000"
            chart.series[0].graphicalProperties.line.width = 15875
            chart.series[1].graphicalProperties.line.solidFill = "00B050"
            chart.series[1].graphicalProperties.line.width = 19050
            chart.series[2].graphicalProperties.line.solidFill = "0070C0"
            chart.series[2].graphicalProperties.line.width = 19050

        _plot_border = GraphicalProperties()
        _plot_border.ln.solidFill = "000000"
        _plot_border.ln.w = 12700
        chart.plot_area.spPr = _plot_border
        ws.add_chart(chart, "I4")

        # Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
        note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
        note1.font = f_data; note1.alignment = left_a
        ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14)
        note2 = ws.cell(row=29, column=9, value="for each specified range of time intervals.")
        note2.font = f_data; note2.alignment = left_a
        ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14)

        # Table header row 31
        med = Side(style='medium')
        tbl_top_left   = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin'))
        tbl_top_mid    = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin'))
        tbl_top_right  = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin'))
        tbl_mid_left   = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
        tbl_mid_mid    = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
        tbl_mid_right  = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin'))
        tbl_bot_left   = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med)
        tbl_bot_mid    = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med)
        tbl_bot_right  = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med)

        hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")

        # Header row: blank | Evening | Nighttime
        c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold
        c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)")
        c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
        c.border = tbl_top_mid; c.fill = hdr_fill_tbl
        ws.merge_cells(start_row=31, start_column=10, end_row=31, end_column=11)
        c = ws.cell(row=31, column=12, value="Nighttime (10PM to 7AM)")
        c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
        c.border = tbl_top_right; c.fill = hdr_fill_tbl
        ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13)
        ws.row_dimensions[31].height = 15

        evening_c   = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_c if 19 <= dt.hour < 22]
        nighttime_c = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_c if dt.hour >= 22 or dt.hour < 7]

        def _avg_c(vals): return round(sum(vals) / len(vals), 1) if vals else None
        def _max_c(vals): return round(max(vals), 1) if vals else None

        def write_stat_c(row_num, label, eve_val, night_val, is_last=False):
            bl = tbl_bot_left  if is_last else tbl_mid_left
            bm = tbl_bot_mid   if is_last else tbl_mid_mid
            br = tbl_bot_right if is_last else tbl_mid_right
            lbl = ws.cell(row=row_num, column=9, value=label)
            lbl.font = f_data; lbl.border = bl
            lbl.alignment = Alignment(horizontal='left', vertical='center')
            ev_str = f"{eve_val} dBA" if eve_val is not None else ""
            ev = ws.cell(row=row_num, column=10, value=ev_str)
            ev.font = f_bold; ev.border = bm
            ev.alignment = Alignment(horizontal='center', vertical='center')
            ws.merge_cells(start_row=row_num, start_column=10, end_row=row_num, end_column=11)
            ni_str = f"{night_val} dBA" if night_val is not None else ""
            ni = ws.cell(row=row_num, column=12, value=ni_str)
            ni.font = f_bold; ni.border = br
            ni.alignment = Alignment(horizontal='center', vertical='center')
            ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13)

        write_stat_c(32, "LAmax",       _max_c([v[0] for v in evening_c]),  _max_c([v[0] for v in nighttime_c]))
        write_stat_c(33, "LA01 Average",_avg_c([v[1] for v in evening_c]),  _avg_c([v[1] for v in nighttime_c]))
        write_stat_c(34, "LA10 Average",_avg_c([v[2] for v in evening_c]),  _avg_c([v[2] for v in nighttime_c]), is_last=True)

        from openpyxl.worksheet.properties import PageSetupProperties
        ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False)
        ws.page_setup.orientation = 'portrait'
        ws.page_setup.paperSize = 1
        ws.page_margins.left   = 0.75
        ws.page_margins.right  = 0.75
        ws.page_margins.top    = 1.0
        ws.page_margins.bottom = 1.0
        ws.page_margins.header = 0.5
        ws.page_margins.footer = 0.5

        # Calculate summary for this location
        # Calculate summary for this location
        all_lmax = [r.get('Lmax(Main)') for r in all_rnd_rows if isinstance(r.get('Lmax(Main)'), (int, float))]
        all_ln1 = [r.get('LN1(Main)') for r in all_rnd_rows if isinstance(r.get('LN1(Main)'), (int, float))]
        all_ln2 = [r.get('LN2(Main)') for r in all_rnd_rows if isinstance(r.get('LN2(Main)'), (int, float))]

        all_location_summaries.append({
            'location': location_name,
            'samples': len(all_rnd_rows),
            'lmax_avg': round(sum(all_lmax) / len(all_lmax), 1) if all_lmax else None,
            'ln1_avg': round(sum(all_ln1) / len(all_ln1), 1) if all_ln1 else None,
            'ln2_avg': round(sum(all_ln2) / len(all_ln2), 1) if all_ln2 else None,
        })

    # Create Summary sheet at the beginning
    summary_ws = wb.create_sheet(title="Summary", index=0)

    summary_ws['A1'] = f"{report_title} - {project.name} - Summary"
    summary_ws['A1'].font = title_font
    summary_ws.merge_cells('A1:E1')

    summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg']
    for col, header in enumerate(summary_headers, 1):
        cell = summary_ws.cell(row=3, column=col, value=header)
        cell.font = header_font
        cell.fill = header_fill
        cell.border = thin_border

    for i, width in enumerate([30, 10, 12, 12, 12], 1):
        summary_ws.column_dimensions[get_column_letter(i)].width = width

    for idx, loc_summary in enumerate(all_location_summaries, 4):
        summary_ws.cell(row=idx, column=1, value=loc_summary['location']).border = thin_border
        summary_ws.cell(row=idx, column=2, value=loc_summary['samples']).border = thin_border
        summary_ws.cell(row=idx, column=3, value=loc_summary['lmax_avg'] or '-').border = thin_border
        summary_ws.cell(row=idx, column=4, value=loc_summary['ln1_avg'] or '-').border = thin_border
        summary_ws.cell(row=idx, column=5, value=loc_summary['ln2_avg'] or '-').border = thin_border

    # Save to buffer
    output = io.BytesIO()
    wb.save(output)
    output.seek(0)

    # Generate filename
    project_name_clean = "".join(c for c in project.name if c.isalnum() or c in ('_', '-', ' ')).strip()
    filename = f"{project_name_clean}_combined_report.xlsx"
    filename = filename.replace(' ', '_')

    return StreamingResponse(
        output,
        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'}
    )


# ============================================================================
# Combined Report Wizard — config page, preview page, and generate endpoint
# ============================================================================

@router.get("/{project_id}/combined-report-wizard", response_class=HTMLResponse)
async def combined_report_wizard(
    request: Request,
    project_id: str,
    db: Session = Depends(get_db),
):
    """Configuration page for the combined multi-location report wizard."""
    from backend.models import ReportTemplate
    from pathlib import Path as _Path

    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")
    _require_module(project, "sound_monitoring", db)

    sessions = db.query(MonitoringSession).filter_by(project_id=project_id).order_by(MonitoringSession.started_at).all()

    # Build location -> sessions list, only including sessions that have Leq files
    location_sessions: dict = {}  # loc_name -> list of session dicts
    for session in sessions:
        files = db.query(DataFile).filter_by(session_id=session.id).all()
        has_leq = False
        for file in files:
            if not file.file_path or not file.file_path.lower().endswith('.rnd'):
                continue
            abs_path = _Path("data") / file.file_path
            peek = _peek_rnd_headers(abs_path)
            if _is_leq_file(file.file_path, peek):
                has_leq = True
                break
        if not has_leq:
            continue

        location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
        loc_name = location.name if location else f"Session {session.id[:8]}"

        if loc_name not in location_sessions:
            location_sessions[loc_name] = []

        # Build a display date and day-of-week from started_at
        date_display = ""
        day_of_week = ""
        if session.started_at:
            date_display = session.started_at.strftime("%-m/%-d/%Y")
            day_of_week = session.started_at.strftime("%A")  # Monday, Sunday, etc.

        location_sessions[loc_name].append({
            "session_id": session.id,
            "session_label": session.session_label or "",
            "date_display": date_display,
            "day_of_week": day_of_week,
            "started_at": session.started_at.isoformat() if session.started_at else "",
            "stopped_at": session.stopped_at.isoformat() if session.stopped_at else "",
            "duration_h": (session.duration_seconds // 3600) if session.duration_seconds else 0,
            "duration_m": ((session.duration_seconds % 3600) // 60) if session.duration_seconds else 0,
            "period_type": session.period_type or "",
            "status": session.status,
        })

    locations = [
        {"name": name, "sessions": sess_list}
        for name, sess_list in sorted(location_sessions.items())
    ]

    report_templates = db.query(ReportTemplate).all()

    return templates.TemplateResponse("combined_report_wizard.html", {
        "request": request,
        "project": project,
        "project_id": project_id,
        "locations": locations,
        "locations_json": json.dumps(locations),
        "report_templates": report_templates,
    })


def _build_location_data_from_sessions(project_id: str, db, selected_session_ids: list) -> dict:
    """
    Build per-location spreadsheet data using an explicit list of session IDs.
    Only rows from those sessions are included.  Per-session period_type is
    stored on each row so the report can filter stats correctly.
    """
    from pathlib import Path as _Path

    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")

    if not selected_session_ids:
        raise HTTPException(status_code=400, detail="No sessions selected.")

    # Load every requested session — one entry per (session_id, loc_name) pair.
    # Keyed by session_id so overnight sessions are never split by calendar date.
    session_entries: dict = {}  # session_id -> {loc_name, session_label, period_type, rows[]}

    for session_id in selected_session_ids:
        session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first()
        if not session:
            continue
        location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
        loc_name = location.name if location else f"Session {session_id[:8]}"

        session_entries[session_id] = {
            "loc_name": loc_name,
            "session_label": session.session_label or "",
            "period_type": session.period_type or "",
            "period_start_hour": session.period_start_hour,
            "period_end_hour": session.period_end_hour,
            "started_at": session.started_at,
            "report_date": session.report_date,
            "rows": [],
        }

        files = db.query(DataFile).filter_by(session_id=session_id).all()
        for file in files:
            if not file.file_path or not file.file_path.lower().endswith('.rnd'):
                continue
            abs_path = _Path("data") / file.file_path
            peek = _peek_rnd_headers(abs_path)
            if not _is_leq_file(file.file_path, peek):
                continue
            rows = _read_rnd_file_rows(file.file_path)
            rows, _ = _normalize_rnd_rows(rows)
            session_entries[session_id]["rows"].extend(rows)

    if not any(e["rows"] for e in session_entries.values()):
        raise HTTPException(status_code=404, detail="No Leq data found in the selected sessions.")

    location_data = []
    for session_id in selected_session_ids:
        entry = session_entries.get(session_id)
        if not entry or not entry["rows"]:
            continue

        loc_name = entry["loc_name"]
        period_type = entry["period_type"]
        raw_rows = sorted(entry["rows"], key=lambda r: r.get('Start Time', ''))

        # Parse all rows to datetimes first so we can apply period-aware filtering
        parsed = []
        for row in raw_rows:
            start_time_str = row.get('Start Time', '')
            dt = None
            if start_time_str:
                try:
                    dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
                except ValueError:
                    pass
            parsed.append((dt, row))

        # Determine effective hour window.
        # Prefer per-session period_start/end_hour; fall back to hardcoded defaults.
        sh = entry.get("period_start_hour")  # e.g. 7 for Day, 19 for Night
        eh = entry.get("period_end_hour")    # e.g. 19 for Day,  7 for Night

        target_date = None
        if period_type == 'full_24h':
            # 24-hour continuous: keep every row (rows get split across
            # Daytime/Evening/Nighttime by hour in the sheet builder). No
            # hour-window filtering and no single target date.
            is_day_session = False
            filtered = [(dt, row) for dt, row in parsed if dt]
        elif sh is None or eh is None:
            # Legacy defaults based on period_type
            is_day_session = period_type in ('weekday_day', 'weekend_day')
            sh = 7 if is_day_session else 19
            eh = 19 if is_day_session else 7
        else:
            is_day_session = eh > sh  # crosses midnight when end < start

        if period_type == 'full_24h':
            pass  # filtered already set above
        elif is_day_session:
            # Day-style: start_h <= hour < end_h, restricted to the LAST calendar date
            in_window = lambda h: sh <= h < eh
            if entry.get("report_date"):
                target_date = entry["report_date"]
            else:
                daytime_dates = sorted({
                    dt.date() for dt, row in parsed if dt and in_window(dt.hour)
                })
                target_date = daytime_dates[-1] if daytime_dates else None
            filtered = [
                (dt, row) for dt, row in parsed
                if dt and dt.date() == target_date and in_window(dt.hour)
            ]
        else:
            # Night-style: hour >= start_h OR hour < end_h (crosses midnight)
            in_window = lambda h: h >= sh or h < eh
            filtered = [
                (dt, row) for dt, row in parsed
                if dt and in_window(dt.hour)
            ]

        # Fall back to all rows if filtering removed everything
        if not filtered:
            filtered = parsed

        spreadsheet_data = []
        for idx, (dt, row) in enumerate(filtered, 1):
            date_str = dt.strftime('%Y-%m-%d') if dt else ''
            time_str = dt.strftime('%H:%M') if dt else ''

            lmax = row.get('Lmax(Main)', '')
            ln1  = row.get('LN1(Main)', '')
            ln2  = row.get('LN2(Main)', '')

            spreadsheet_data.append([
                idx,
                date_str,
                time_str,
                lmax if lmax else '',
                ln1  if ln1  else '',
                ln2  if ln2  else '',
                '',
                period_type,   # col index 7 — hidden, used by report gen for day/night bucketing
            ])

        # For the label/filename, use target_date (day sessions) or started_at (night sessions)
        from datetime import timedelta as _td
        started_at_dt = entry["started_at"]
        if is_day_session and target_date:
            # Use the actual target date from data filtering (last date with daytime rows)
            label_dt = datetime.combine(target_date, datetime.min.time())
        else:
            label_dt = started_at_dt

        # Rebuild session label using the correct label date
        if label_dt and entry["loc_name"]:
            period_str = {"weekday_day": "Day", "weekday_night": "Night",
                          "weekend_day": "Day", "weekend_night": "Night",
                          "full_24h": "24-Hour"}.get(period_type, "")
            day_abbr = label_dt.strftime("%a")
            date_label = f"{label_dt.month}/{label_dt.day}"
            session_label = " — ".join(p for p in [loc_name, f"{day_abbr} {date_label}", period_str] if p)
        else:
            session_label = entry["session_label"]

        location_data.append({
            "session_id": session_id,
            "location_name": loc_name,
            "session_label": session_label,
            "period_type": period_type,
            "started_at": label_dt.isoformat() if label_dt else "",
            "raw_count": len(raw_rows),
            "filtered_count": len(filtered),
            "spreadsheet_data": spreadsheet_data,
        })

    return {"project": project, "location_data": location_data}


@router.get("/{project_id}/combined-report-preview", response_class=HTMLResponse)
async def combined_report_preview(
    request: Request,
    project_id: str,
    report_title: str = Query("Background Noise Study"),
    project_name: str = Query(""),
    client_name: str = Query(""),
    selected_sessions: str = Query(""),  # comma-separated session IDs
    db: Session = Depends(get_db),
):
    """Preview and edit combined report data before generating the Excel file."""
    session_ids = [s.strip() for s in selected_sessions.split(',') if s.strip()] if selected_sessions else []

    result = _build_location_data_from_sessions(project_id, db, session_ids)

    project = result["project"]
    location_data = result["location_data"]
    total_rows = sum(loc["filtered_count"] for loc in location_data)
    final_project_name = project_name if project_name else project.name

    return templates.TemplateResponse("combined_report_preview.html", {
        "request": request,
        "project": project,
        "project_id": project_id,
        "report_title": report_title,
        "project_name": final_project_name,
        "client_name": client_name,
        "time_filter_desc": f"{len(session_ids)} session{'s' if len(session_ids) != 1 else ''} selected",
        "location_data": location_data,
        "locations_json": json.dumps(location_data),
        "total_rows": total_rows,
    })


@router.post("/{project_id}/generate-combined-from-preview")
async def generate_combined_from_preview(
    project_id: str,
    data: dict,
    db: Session = Depends(get_db),
):
    """Generate combined Excel report from wizard-edited spreadsheet data.

    Produces one .xlsx per day (each with one sheet per location) packaged
    into a single .zip file for download.
    """
    try:
        import openpyxl
        from openpyxl.chart import LineChart, Reference
        from openpyxl.chart.shapes import GraphicalProperties
        from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
        from openpyxl.utils import get_column_letter
        from openpyxl.worksheet.properties import PageSetupProperties
    except ImportError:
        raise HTTPException(status_code=500, detail="openpyxl is not installed. Run: pip install openpyxl")

    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")
    _require_module(project, "sound_monitoring", db)

    report_title = data.get("report_title", "Background Noise Study")
    project_name = data.get("project_name", project.name)
    client_name = data.get("client_name", "")
    locations = data.get("locations", [])

    if not locations:
        raise HTTPException(status_code=400, detail="No location data provided")

    # Shared styles
    f_title = Font(name='Arial', bold=True, size=12)
    f_bold  = Font(name='Arial', bold=True, size=10)
    f_data  = Font(name='Arial', size=10)
    thin = Side(style='thin')
    dbl  = Side(style='double')
    med  = Side(style='medium')
    hdr_inner  = Border(left=thin, right=thin, top=dbl,  bottom=thin)
    hdr_left   = Border(left=dbl,  right=thin, top=dbl,  bottom=thin)
    hdr_right  = Border(left=thin, right=dbl,  top=dbl,  bottom=thin)
    last_inner = Border(left=thin, right=thin, top=thin, bottom=dbl)
    last_left  = Border(left=dbl,  right=thin, top=thin, bottom=dbl)
    last_right = Border(left=thin, right=dbl,  top=thin, bottom=dbl)
    data_inner = Border(left=thin, right=thin, top=thin, bottom=thin)
    data_left  = Border(left=dbl,  right=thin, top=thin, bottom=thin)
    data_right = Border(left=thin, right=dbl,  top=thin, bottom=thin)
    hdr_fill   = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
    center_a   = Alignment(horizontal='center', vertical='center', wrap_text=True)
    left_a     = Alignment(horizontal='left',   vertical='center')
    thin_border = Border(left=thin, right=thin, top=thin, bottom=thin)

    tbl_top_left   = Border(left=med, right=thin, top=med, bottom=thin)
    tbl_top_mid    = Border(left=thin, right=thin, top=med, bottom=thin)
    tbl_top_right  = Border(left=thin, right=med, top=med, bottom=thin)
    tbl_mid_left   = Border(left=med, right=thin, top=thin, bottom=thin)
    tbl_mid_mid    = Border(left=thin, right=thin, top=thin, bottom=thin)
    tbl_mid_right  = Border(left=thin, right=med, top=thin, bottom=thin)
    tbl_bot_left   = Border(left=med, right=thin, top=thin, bottom=med)
    tbl_bot_mid    = Border(left=thin, right=thin, top=thin, bottom=med)
    tbl_bot_right  = Border(left=thin, right=med, top=thin, bottom=med)

    col_widths = [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 18.0, 18.0, 14.0, 14.0, 10.0, 8.0, 6.43, 6.43]

    def _build_location_sheet(ws, loc_name, day_rows, final_title):
        """Write one location's data onto ws. day_rows is a list of spreadsheet row arrays."""
        for col_i, col_w in zip(range(1, 17), col_widths):
            ws.column_dimensions[get_column_letter(col_i)].width = col_w

        ws.merge_cells('A1:G1')
        ws['A1'] = final_title
        ws['A1'].font = f_title; ws['A1'].alignment = center_a
        ws.row_dimensions[1].height = 15.75
        ws.row_dimensions[2].height = 15

        ws.merge_cells('A3:G3')
        ws['A3'] = loc_name
        ws['A3'].font = f_title; ws['A3'].alignment = center_a
        ws.row_dimensions[3].height = 15.75

        # Row 4: date range derived from the data rows
        def _fmt_date(d):
            try:
                from datetime import datetime as _dt
                return _dt.strptime(d, '%Y-%m-%d').strftime('%-m/%-d/%y')
            except Exception:
                return d

        dates_in_data = sorted({
            row[1] for row in day_rows
            if len(row) > 1 and row[1]
        })
        if len(dates_in_data) >= 2:
            date_label = f"{_fmt_date(dates_in_data[0])} to {_fmt_date(dates_in_data[-1])}"
        elif len(dates_in_data) == 1:
            date_label = _fmt_date(dates_in_data[0])
        else:
            date_label = ""
        ws.merge_cells('A4:G4')
        ws['A4'] = date_label
        ws['A4'].font = f_data; ws['A4'].alignment = center_a
        ws.row_dimensions[4].height = 15
        ws.row_dimensions[5].height = 15.75

        hdr_labels = ['Interval #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
        for col, label in enumerate(hdr_labels, 1):
            cell = ws.cell(row=6, column=col, value=label)
            cell.font = f_bold; cell.fill = hdr_fill; cell.alignment = center_a
            cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner)
        ws.row_dimensions[6].height = 39

        data_start_row = 7
        parsed_rows = []
        lmax_vals, ln1_vals, ln2_vals = [], [], []

        for row_idx, row in enumerate(day_rows):
            dr = data_start_row + row_idx
            is_last = (row_idx == len(day_rows) - 1)
            b_left  = last_left  if is_last else data_left
            b_inner = last_inner if is_last else data_inner
            b_right = last_right if is_last else data_right

            test_num   = row[0] if len(row) > 0 else row_idx + 1
            date_val   = _fmt_date(row[1]) if len(row) > 1 and row[1] else ''
            time_val   = row[2] if len(row) > 2 else ''
            lmax       = row[3] if len(row) > 3 else ''
            ln1        = row[4] if len(row) > 4 else ''
            ln2        = row[5] if len(row) > 5 else ''
            comment    = row[6] if len(row) > 6 else ''
            row_period = row[7] if len(row) > 7 else ''  # hidden period_type from session

            c = ws.cell(row=dr, column=1, value=test_num)
            c.font = f_data; c.alignment = center_a; c.border = b_left
            c = ws.cell(row=dr, column=2, value=date_val)
            c.font = f_data; c.alignment = center_a; c.border = b_inner
            c = ws.cell(row=dr, column=3, value=time_val)
            c.font = f_data; c.alignment = center_a; c.border = b_inner
            c = ws.cell(row=dr, column=4, value=lmax if lmax != '' else None)
            c.font = f_data; c.alignment = center_a; c.border = b_inner
            c = ws.cell(row=dr, column=5, value=ln1 if ln1 != '' else None)
            c.font = f_data; c.alignment = center_a; c.border = b_inner
            c = ws.cell(row=dr, column=6, value=ln2 if ln2 != '' else None)
            c.font = f_data; c.alignment = center_a; c.border = b_inner
            c = ws.cell(row=dr, column=7, value=comment)
            c.font = f_data; c.alignment = left_a; c.border = b_right
            ws.row_dimensions[dr].height = 15

            if isinstance(lmax, (int, float)):
                lmax_vals.append(lmax)
            if isinstance(ln1, (int, float)):
                ln1_vals.append(ln1)
            if isinstance(ln2, (int, float)):
                ln2_vals.append(ln2)

            if isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)):
                parsed_rows.append((row_period, time_val, float(lmax), float(ln1), float(ln2)))

        data_end_row = data_start_row + len(day_rows) - 1

        chart = LineChart()
        chart.title = f"{loc_name} - {final_title}"
        chart.style = 2
        chart.y_axis.title = "Sound Level (dBA)"
        chart.x_axis.title = "Time Period (15 Minute Intervals)"
        chart.height = 12.7
        chart.width = 15.7

        data_ref = Reference(ws, min_col=4, min_row=6, max_col=6, max_row=data_end_row)
        categories = Reference(ws, min_col=3, min_row=data_start_row, max_row=data_end_row)
        chart.add_data(data_ref, titles_from_data=True)
        chart.set_categories(categories)

        if len(chart.series) >= 3:
            chart.series[0].graphicalProperties.line.solidFill = "C00000"
            chart.series[0].graphicalProperties.line.width = 15875
            chart.series[1].graphicalProperties.line.solidFill = "00B050"
            chart.series[1].graphicalProperties.line.width = 19050
            chart.series[2].graphicalProperties.line.solidFill = "0070C0"
            chart.series[2].graphicalProperties.line.width = 19050

        _plot_border = GraphicalProperties()
        _plot_border.ln.solidFill = "000000"
        _plot_border.ln.w = 12700
        chart.plot_area.spPr = _plot_border
        ws.add_chart(chart, "I4")

        hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")

        def _avg(vals): return round(sum(vals) / len(vals), 1) if vals else None
        def _max(vals): return round(max(vals), 1) if vals else None

        # --- Period bucketing ------------------------------------------------
        # For night sessions: split into Evening (7PM–10PM) and Nighttime (10PM–7AM).
        # For day sessions: single Daytime bucket.
        PERIOD_TYPE_IS_DAY   = {"weekday_day", "weekend_day"}
        PERIOD_TYPE_IS_NIGHT = {"weekday_night", "weekend_night"}

        day_rows_data     = []
        evening_rows_data = []
        night_rows_data   = []

        def _row_hour(time_v):
            if time_v and ':' in str(time_v):
                try:
                    return int(str(time_v).split(':')[0])
                except ValueError:
                    pass
            return 0

        for pt, time_v, lmx, l1, l2 in parsed_rows:
            if pt in PERIOD_TYPE_IS_DAY:
                day_rows_data.append((lmx, l1, l2))
            elif pt in PERIOD_TYPE_IS_NIGHT:
                # Split by time: Evening = 19:00–21:59, Nighttime = 22:00–06:59
                hour = _row_hour(time_v)
                if 19 <= hour <= 21:
                    evening_rows_data.append((lmx, l1, l2))
                else:
                    night_rows_data.append((lmx, l1, l2))
            elif pt == 'full_24h':
                # 24-hour continuous: split each row by hour into the three
                # non-overlapping buckets (Daytime 7–18:59, Evening 19–21:59,
                # Nighttime 22:00–06:59). Empty buckets are dropped downstream.
                hour = _row_hour(time_v)
                if 7 <= hour < 19:
                    day_rows_data.append((lmx, l1, l2))
                elif 19 <= hour <= 21:
                    evening_rows_data.append((lmx, l1, l2))
                else:
                    night_rows_data.append((lmx, l1, l2))
            else:
                day_rows_data.append((lmx, l1, l2))

        all_candidate_periods = [
            ("Daytime (7AM to 10PM)",   day_rows_data),
            ("Evening (7PM to 10PM)",   evening_rows_data),
            ("Nighttime (10PM to 7AM)", night_rows_data),
        ]
        active_periods = [(label, rows) for label, rows in all_candidate_periods if rows]
        if not active_periods:
            active_periods = [("Daytime (7AM to 10PM)", [])]

        # --- Stats table — fixed position alongside the chart ---
        note1 = ws.cell(row=28, column=9,
                        value="Note: Averages are calculated by determining the arithmetic average ")
        note1.font = f_data; note1.alignment = left_a
        ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14)
        note2 = ws.cell(row=29, column=9,
                        value="for each specified range of time intervals.")
        note2.font = f_data; note2.alignment = left_a
        ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14)

        for r in [28, 29, 30, 31, 32, 33, 34]:
            ws.row_dimensions[r].height = 15

        tbl_hdr_row  = 31
        tbl_data_row = 32

        # Layout: col 9 = row label, then pairs: (10,11), (12,13), (14,15)
        num_periods = len(active_periods)
        period_start_cols = [10 + i * 2 for i in range(num_periods)]

        def _hdr_border(i, n):
            return Border(
                left=med if i == 0 else thin,
                right=med if i == n - 1 else thin,
                top=med, bottom=thin,
            )

        c = ws.cell(row=tbl_hdr_row, column=9, value=""); c.border = tbl_top_left; c.font = f_bold

        for i, (period_label, _) in enumerate(active_periods):
            sc = period_start_cols[i]
            c = ws.cell(row=tbl_hdr_row, column=sc, value=period_label)
            c.font = f_bold
            c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=False)
            c.border = _hdr_border(i, num_periods)
            c.fill = hdr_fill_tbl
            ws.merge_cells(start_row=tbl_hdr_row, start_column=sc,
                           end_row=tbl_hdr_row,   end_column=sc + 1)

        def write_stat_dynamic(row_num, row_label, period_vals_list, is_last=False):
            lbl = ws.cell(row=row_num, column=9, value=row_label)
            lbl.font = f_data; lbl.border = tbl_bot_left if is_last else tbl_mid_left
            lbl.alignment = Alignment(horizontal='left', vertical='center')
            n = len(period_vals_list)
            for i, val in enumerate(period_vals_list):
                sc = period_start_cols[i]
                val_str = f"{val} dBA" if val is not None else ""
                c = ws.cell(row=row_num, column=sc, value=val_str)
                c.font = f_bold
                c.alignment = Alignment(horizontal='center', vertical='center')
                c.border = Border(
                    left=med if i == 0 else thin,
                    right=med if i == n - 1 else thin,
                    top=tbl_bot_mid.top if is_last else tbl_mid_mid.top,
                    bottom=tbl_bot_mid.bottom if is_last else tbl_mid_mid.bottom,
                )
                ws.merge_cells(start_row=row_num, start_column=sc,
                               end_row=row_num,   end_column=sc + 1)

        write_stat_dynamic(tbl_data_row,     "LAmax",
            [_max([v[0] for v in rows]) for _, rows in active_periods])
        write_stat_dynamic(tbl_data_row + 1, "LA01 Average",
            [_avg([v[1] for v in rows]) for _, rows in active_periods])
        write_stat_dynamic(tbl_data_row + 2, "LA10 Average",
            [_avg([v[2] for v in rows]) for _, rows in active_periods], is_last=True)

        ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False)
        ws.page_setup.orientation = 'portrait'
        ws.page_setup.paperSize = 1
        ws.page_margins.left   = 0.75
        ws.page_margins.right  = 0.75
        ws.page_margins.top    = 1.0
        ws.page_margins.bottom = 1.0
        ws.page_margins.header = 0.5
        ws.page_margins.footer = 0.5

        return {
            'location': loc_name,
            'samples': len(day_rows),
            'lmax_avg': round(sum(lmax_vals) / len(lmax_vals), 1) if lmax_vals else None,
            'ln1_avg':  round(sum(ln1_vals)  / len(ln1_vals),  1) if ln1_vals  else None,
            'ln2_avg':  round(sum(ln2_vals)  / len(ln2_vals),  1) if ln2_vals  else None,
        }

    def _build_summary_sheet(wb, day_label, project_name, loc_summaries):
        summary_ws = wb.create_sheet(title="Summary")
        summary_ws['A1'] = f"{report_title} - {project_name} - {day_label}"
        summary_ws['A1'].font = f_title
        summary_ws.merge_cells('A1:E1')
        summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg']
        for col, header in enumerate(summary_headers, 1):
            cell = summary_ws.cell(row=3, column=col, value=header)
            cell.font = f_bold; cell.fill = hdr_fill; cell.border = thin_border
        for i, width in enumerate([30, 10, 12, 12, 12], 1):
            summary_ws.column_dimensions[get_column_letter(i)].width = width
        for idx, s in enumerate(loc_summaries, 4):
            summary_ws.cell(row=idx, column=1, value=s['location']).border = thin_border
            summary_ws.cell(row=idx, column=2, value=s['samples']).border = thin_border
            summary_ws.cell(row=idx, column=3, value=s['lmax_avg'] or '-').border = thin_border
            summary_ws.cell(row=idx, column=4, value=s['ln1_avg']  or '-').border = thin_border
            summary_ws.cell(row=idx, column=5, value=s['ln2_avg']  or '-').border = thin_border

    # ----------------------------------------------------------------
    # Build one workbook per session (each location entry is one session)
    # ----------------------------------------------------------------
    if not locations:
        raise HTTPException(status_code=400, detail="No location data provided")

    project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip().replace(' ', '_')
    final_title = f"{report_title} - {project_name}"

    zip_buffer = io.BytesIO()
    with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
        for loc_info in locations:
            loc_name    = loc_info.get("location_name", "Unknown")
            session_label = loc_info.get("session_label", "")
            period_type = loc_info.get("period_type", "")
            started_at_str = loc_info.get("started_at", "")
            rows = loc_info.get("spreadsheet_data", [])
            if not rows:
                continue

            # Re-number interval # sequentially
            for i, row in enumerate(rows):
                if len(row) > 0:
                    row[0] = i + 1

            wb = openpyxl.Workbook()
            wb.remove(wb.active)

            safe_sheet = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31]
            ws = wb.create_sheet(title=safe_sheet)
            summary = _build_location_sheet(ws, loc_name, rows, final_title)

            # Derive a date label for the summary sheet from started_at or first row
            day_label = session_label or loc_name
            if started_at_str:
                try:
                    _dt = datetime.fromisoformat(started_at_str)
                    day_label = _dt.strftime('%-m/%-d/%Y')
                    if session_label:
                        day_label = session_label
                except Exception:
                    pass

            _build_summary_sheet(wb, day_label, project_name, [summary])

            xlsx_buf = io.BytesIO()
            wb.save(xlsx_buf)
            xlsx_buf.seek(0)

            # Build a clean filename from label or location+date
            label_clean = session_label or loc_name
            label_clean = "".join(c for c in label_clean if c.isalnum() or c in (' ', '-', '_', '/')).strip().replace(' ', '_').replace('/', '-')
            xlsx_name = f"{label_clean}_{project_name_clean}_report.xlsx"
            zf.writestr(xlsx_name, xlsx_buf.read())

    zip_buffer.seek(0)
    zip_filename = f"{project_name_clean}_reports.zip"

    return StreamingResponse(
        zip_buffer,
        media_type="application/zip",
        headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'}
    )


# ============================================================================
# Project-level bulk upload (entire date-folder structure)
# ============================================================================

def _bulk_parse_rnh(content: bytes) -> dict:
    """Parse a Rion .rnh metadata file for session start/stop times and device info."""
    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()
                mapping = {
                    "Serial Number": "serial_number",
                    "Store Name": "store_name",
                    "Index Number": "index_number",
                    # NL-43/NL-53 use "Measurement Start/Stop Time"
                    "Measurement Start Time": "start_time_str",
                    "Measurement Stop Time": "stop_time_str",
                    "Total Measurement Time": "total_time_str",
                    # AU2/NL-32 use bare "Start Time" / "Stop Time"
                    "Start Time": "start_time_str",
                    "Stop Time": "stop_time_str",
                }
                if key in mapping:
                    result[mapping[key]] = value
    except Exception:
        pass
    return result


def _bulk_parse_datetime(s: str):
    if not s:
        return None
    try:
        return datetime.strptime(s.strip(), "%Y/%m/%d %H:%M:%S")
    except Exception:
        return None


def _bulk_classify_file(filename: str) -> str:
    name = filename.lower()
    if name.endswith(".rnh"):
        return "log"
    if name.endswith(".rnd"):
        return "measurement"
    if name.endswith(".mp3") or name.endswith(".wav") or name.endswith(".m4a"):
        return "audio"
    if name.endswith(".xlsx") or name.endswith(".xls") or name.endswith(".csv"):
        return "data"
    return "data"


# Files we skip entirely — already-converted outputs that don't need re-importing
_BULK_SKIP_EXTENSIONS = {".xlsx", ".xls"}


@router.post("/{project_id}/upload-all")
async def upload_all_project_data(
    project_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """
    Bulk-import an entire structured data folder selected via webkitdirectory.

    Expected folder structure (flexible depth):
        [date_folder]/[NRL_name]/[Auto_####]/  ← files here
        -- OR --
        [NRL_name]/[Auto_####]/                ← files here (no date wrapper)
        -- OR --
        [date_folder]/[NRL_name]/              ← files directly in NRL folder

    Each leaf folder group of .rnd/.rnh files becomes one MonitoringSession.
    NRL folder names are matched case-insensitively to MonitoringLocation.name.
    .mp3 files are stored as audio. .xlsx/.xls are skipped (already-converted).
    Unmatched folders are reported but don't cause failure.
    """
    form = await request.form()

    # Collect (relative_path, filename, bytes) for every uploaded file.
    # The JS sends each file as "files" and its webkitRelativePath as "paths".
    from collections import defaultdict

    uploaded_files = form.getlist("files")
    uploaded_paths = form.getlist("paths")

    if not uploaded_files:
        raise HTTPException(status_code=400, detail="No files received.")

    if len(uploaded_paths) != len(uploaded_files):
        # Fallback: use bare filename if paths weren't sent
        uploaded_paths = [f.filename for f in uploaded_files]

    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        raise HTTPException(status_code=404, detail="Project not found")
    _require_module(project, "sound_monitoring", db)

    # Load all sound monitoring locations for this project
    locations = db.query(MonitoringLocation).filter_by(
        project_id=project_id,
        location_type="sound",
    ).all()

    # Build a case-insensitive name → location map
    loc_by_name: dict[str, MonitoringLocation] = {
        loc.name.strip().lower(): loc for loc in locations
    }

    def _normalize(s: str) -> str:
        """Lowercase, strip spaces/hyphens/underscores for fuzzy comparison."""
        return s.lower().replace(" ", "").replace("-", "").replace("_", "")

    # Pre-build normalized keys for fuzzy matching
    loc_by_normalized: dict[str, MonitoringLocation] = {
        _normalize(loc.name): loc for loc in locations
    }

    def _find_location_for_path(path: str):
        """
        Walk path components from right and return first matching location.
        Tries exact match first, then normalized (strips spaces/hyphens/underscores),
        then checks if the location name *starts with* the normalized folder name.
        e.g. folder "NRL 1" matches location "NRL1 - Test Location"
        """
        components = path.replace("\\", "/").split("/")
        for comp in reversed(components):
            # Exact match
            key = comp.strip().lower()
            if key in loc_by_name:
                return loc_by_name[key]
            # Normalized match ("NRL 1" == "NRL1")
            norm = _normalize(comp)
            if norm in loc_by_normalized:
                return loc_by_normalized[norm]
            # Prefix match: location name starts with the folder component
            # e.g. "NRL1" matches "NRL1 - Test Location"
            for loc_norm, loc in loc_by_normalized.items():
                if loc_norm.startswith(norm) or norm.startswith(loc_norm):
                    return loc
        return None

    def _session_group_key(parts: tuple) -> str:
        """
        Determine the grouping key for a file path.
        Files inside Auto_####/Auto_Leq/ or Auto_####/Auto_Lp_01/ are collapsed
        up to their Auto_#### parent so they all land in the same session.
        Only folder components are examined (not the filename, which is parts[-1]).
        """
        # Only look at folder components — exclude the filename (last part)
        folder_parts = parts[:-1]
        auto_idx = None
        for i, p in enumerate(folder_parts):
            p_lower = p.lower()
            if p_lower.startswith("auto_") and not p_lower.startswith("auto_leq") and not p_lower.startswith("auto_lp"):
                auto_idx = i
        if auto_idx is not None:
            # Group key = everything up to and including Auto_####
            return "/".join(folder_parts[:auto_idx + 1])
        # Fallback: use the immediate parent folder
        return "/".join(folder_parts) if folder_parts else ""

    # --- Group files by session key ---
    groups: dict[str, list[tuple[str, bytes]]] = defaultdict(list)

    for rel_path, uf in zip(uploaded_paths, uploaded_files):
        rel_path = rel_path.replace("\\", "/").strip("/")
        parts = _pathlib.PurePosixPath(rel_path).parts
        if not parts:
            continue
        fname = parts[-1]
        # Skip already-converted Excel exports
        if _pathlib.PurePosixPath(fname).suffix.lower() in _BULK_SKIP_EXTENSIONS:
            continue
        group_key = _session_group_key(parts)
        data = await uf.read()
        groups[group_key].append((fname, data))

    # Aggregate by (location_id, date_label) so each Auto_#### group is one session
    # key: (location_id or None, group_path)
    session_results = []
    unmatched_paths = []
    total_files = 0
    total_sessions = 0

    for group_path, file_list in sorted(groups.items()):
        matched_loc = _find_location_for_path(group_path)

        if matched_loc is None:
            unmatched_paths.append(group_path)
            continue

        # Parse .rnh if present in this group
        rnh_meta = {}
        for fname, fbytes in file_list:
            if fname.lower().endswith(".rnh"):
                rnh_meta = _bulk_parse_rnh(fbytes)
                break

        started_at = _bulk_parse_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
        stopped_at = _bulk_parse_datetime(rnh_meta.get("stop_time_str"))
        duration_seconds = None
        if started_at and stopped_at:
            duration_seconds = int((stopped_at - started_at).total_seconds())

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

        # Detect device model from first RND file in this group (in-memory)
        _bulk_device_model = None
        for _fname, _fbytes in file_list:
            if _fname.lower().endswith(".rnd"):
                try:
                    import csv as _csv_dm, io as _io_dm
                    _text = _fbytes.decode("utf-8", errors="replace")
                    _reader = _csv_dm.DictReader(_io_dm.StringIO(_text))
                    _first = next(_reader, None)
                    if _first and "LAeq" in _first:
                        _bulk_device_model = "NL-32"
                    # NL-43/NL-53 have no distinguishing marker vs each other
                    # at the format level; leave None for those.
                except Exception:
                    pass
                break

        session_id = str(uuid.uuid4())
        monitoring_session = MonitoringSession(
            id=session_id,
            project_id=project_id,
            location_id=matched_loc.id,
            unit_id=None,
            session_type="sound",
            started_at=started_at,
            stopped_at=stopped_at,
            duration_seconds=duration_seconds,
            status="completed",
            device_model=_bulk_device_model,
            session_metadata=json.dumps({
                "source": "bulk_upload",
                "group_path": group_path,
                "store_name": store_name,
                "serial_number": serial_number,
                "index_number": index_number,
            }),
        )
        db.add(monitoring_session)
        db.commit()
        db.refresh(monitoring_session)

        # Write files
        output_dir = _pathlib.Path("data/Projects") / project_id / session_id
        output_dir.mkdir(parents=True, exist_ok=True)

        leq_count = 0
        lp_count = 0
        group_file_count = 0

        for fname, fbytes in file_list:
            fname_lower = fname.lower()
            # For NL-43/NL-53 devices (not NL-32), skip Lp RND files — they are
            # not needed for reports and only add noise. AU2/NL-32 files don't
            # use this naming convention so they are unaffected.
            if (
                fname_lower.endswith(".rnd")
                and "_lp" in fname_lower
                and _bulk_device_model != "NL-32"
            ):
                lp_count += 1
                continue

            file_type = _bulk_classify_file(fname)
            if fname_lower.endswith(".rnd") and "_leq_" in fname_lower:
                leq_count += 1

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

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

        db.commit()
        total_files += group_file_count
        total_sessions += 1

        session_results.append({
            "location_name": matched_loc.name,
            "location_id": matched_loc.id,
            "session_id": session_id,
            "group_path": group_path,
            "files": group_file_count,
            "leq_files": leq_count,
            "lp_files": lp_count,
            "store_name": store_name,
            "started_at": started_at.isoformat() if started_at else None,
        })

    return {
        "success": True,
        "sessions_created": total_sessions,
        "files_imported": total_files,
        "unmatched_folders": unmatched_paths,
        "sessions": session_results,
    }


@router.get("/types/list", response_class=HTMLResponse)
async def get_project_types(request: Request, db: Session = Depends(get_db)):
    """
    Get all available project types.
    Returns HTML partial with project type cards.
    """
    project_types = db.query(ProjectType).all()

    return templates.TemplateResponse("partials/projects/project_type_cards.html", {
        "request": request,
        "project_types": project_types,
    })
