import json
import logging
from typing import Optional
from uuid import UUID

from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession

from src.apps.plots.schemas.requests import (
    ChangePlotStatusRequest,
    CreatePlotRequest,
    SuggestMappingRequest,
    UpdatePlotRequest,
)
from src.apps.plots.schemas.responses import PlotResponse
from src.apps.plots.services.plot_import import (
    PlotImportService,
    parse_file,
    suggest_mapping,
)
from src.apps.plots.services.plot_service import PlotService
from src.core.constants import UserRole
from src.core.dependencies import require_min_role, require_roles
from src.core.exceptions import (
    NotFoundError,
    PayloadTooLargeError,
    RateLimitError,
    UnsupportedMediaTypeError,
)
from src.core.schemas.response import paginated, success
from src.core.utils.geometry import geometry_to_geojson
from src.database.session import get_db, get_redis

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/plots", tags=["Plots"])

# ── Bulk-import upload guards (SEC-04 / SEC-06 / API4) ───────────────────────
_MAX_UPLOAD_BYTES = 25 * 1024 * 1024  # 25 MB raw upload cap → 413
_ALLOWED_CONTENT_TYPES = {
    "text/csv", "application/csv", "text/plain",
    "application/vnd.ms-excel",
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    "application/octet-stream",  # some browsers send this for .csv/.xlsx
    "",
}
_ALLOWED_EXTENSIONS = (".csv", ".xlsx", ".xls")
_IMPORT_RATE_LIMIT = 3       # imports allowed ...
_IMPORT_RATE_WINDOW = 60     # ... per this many seconds, per tenant (SEC-07)


async def _read_upload(file: UploadFile) -> bytes:
    """Validate media type + size, then return the raw bytes (never persisted)."""
    filename = (file.filename or "").lower()
    if not filename.endswith(_ALLOWED_EXTENSIONS):
        raise UnsupportedMediaTypeError("Unsupported file type — upload CSV or Excel")
    if (file.content_type or "") not in _ALLOWED_CONTENT_TYPES:
        raise UnsupportedMediaTypeError(
            f"Unsupported content type '{file.content_type}' — upload CSV or Excel"
        )
    content = await file.read()
    if len(content) > _MAX_UPLOAD_BYTES:
        raise PayloadTooLargeError("File exceeds the 25 MB upload limit")
    return content


async def _enforce_import_rate_limit(tenant_id: UUID) -> None:
    """Redis fixed-window counter: max 3 imports / 60s / tenant (SEC-07 / API4).

    Fails open if Redis is unreachable — availability over strictness here.
    """
    try:
        redis = await get_redis()
        key = f"plots:import:rate:{tenant_id}"
        count = await redis.incr(key)
        if count == 1:
            await redis.expire(key, _IMPORT_RATE_WINDOW)
        if count > _IMPORT_RATE_LIMIT:
            raise RateLimitError(
                "Too many imports — wait a minute before trying again.",
                retry_after=_IMPORT_RATE_WINDOW,
            )
    except RateLimitError:
        raise
    except Exception:  # pragma: no cover - redis outage must not block imports
        logger.warning("import rate-limit check skipped (redis unavailable)")


def _serialize_plot(plot) -> dict:
    """Serialize a Plot ORM object to a dict, enriching with joined relationship fields."""
    data = PlotResponse.model_validate(plot).model_dump()
    # Enrich with denormalized names from loaded relationships
    if plot.section is not None:
        data["section_code"] = plot.section.code
    if plot.plot_type is not None:
        data["plot_type_name"] = plot.plot_type.name
    data["display_ref"] = plot.label_manual or plot.plot_ref
    data["geometry"] = geometry_to_geojson(plot.geometry)
    return data


@router.get("", response_model=dict)
async def list_plots(
    skip: int = Query(0, ge=0),
    limit: int = Query(50, ge=1, le=200),
    section_id: UUID = Query(None),
    status: str = Query(None),
    search: Optional[str] = Query(None),
    current_user=Depends(
        require_roles(
            UserRole.ADMINISTRATOR,
            UserRole.MANAGER,
            UserRole.STAFF,
            UserRole.VIEW_ONLY,
        )
    ),
    db: AsyncSession = Depends(get_db),
):
    """List plots with optional filtering by section and/or status."""
    service = PlotService(db)
    plots, total = await service.list_plots(
        tenant_id=current_user.tenant_id,
        skip=skip,
        limit=limit,
        section_id=section_id,
        status=status,
        search=search,
    )
    return paginated(
        items=[_serialize_plot(p) for p in plots],
        total=total,
        page=(skip // limit) + 1 if limit else 1,
        page_size=limit,
    )


@router.post("", response_model=dict, status_code=201)
async def create_plot(
    body: CreatePlotRequest,
    current_user=Depends(
        require_roles(UserRole.ADMINISTRATOR, UserRole.MANAGER)
    ),
    db: AsyncSession = Depends(get_db),
):
    """Create a new plot."""
    service = PlotService(db)
    plot = await service.create(
        tenant_id=current_user.tenant_id,
        data=body.model_dump(),
    )
    return success(
        data=_serialize_plot(plot),
        message="Plot created",
    )


@router.get("/map", response_model=dict)
async def get_plots_map(
    response: Response,
    bbox: Optional[str] = Query(
        None,
        description="Optional 'minLng,minLat,maxLng,maxLat' viewport filter "
        "(accepted for forward-compat; not yet enforced at current scale).",
    ),
    zoom: Optional[int] = Query(None, ge=0, le=22),
    current_user=Depends(require_min_role(UserRole.VIEW_ONLY)),
    db: AsyncSession = Depends(get_db),
):
    """Single-call map bootstrap: sections + plots GeoJSON + status catalog + map_mode.

    Colours are styled from the tenant's ``plot_statuses`` catalog so the map,
    list, heat-map and legend never drift (AC-01/02/03/17).
    """
    # Short-lived / no intermediary caching of plot data (API8 / SAC-09).
    response.headers["Cache-Control"] = "no-store"
    service = PlotService(db)
    data = await service.list_for_map(tenant_id=current_user.tenant_id)
    return success(data=data)


@router.get("/{plot_id}", response_model=dict)
async def get_plot(
    plot_id: UUID,
    current_user=Depends(
        require_roles(
            UserRole.ADMINISTRATOR,
            UserRole.MANAGER,
            UserRole.STAFF,
            UserRole.VIEW_ONLY,
        )
    ),
    db: AsyncSession = Depends(get_db),
):
    """Get a single plot by ID."""
    service = PlotService(db)
    plot = await service.get_by_id(plot_id, current_user.tenant_id)
    if not plot:
        raise NotFoundError("Plot not found")
    return success(data=_serialize_plot(plot))


@router.patch("/{plot_id}", response_model=dict)
async def update_plot(
    plot_id: UUID,
    body: UpdatePlotRequest,
    current_user=Depends(
        require_roles(UserRole.ADMINISTRATOR, UserRole.MANAGER)
    ),
    db: AsyncSession = Depends(get_db),
):
    """Update plot metadata."""
    service = PlotService(db)
    plot = await service.update(
        plot_id=plot_id,
        tenant_id=current_user.tenant_id,
        data=body.model_dump(exclude_none=True),
    )
    return success(
        data=_serialize_plot(plot),
        message="Plot updated",
    )


@router.delete("/{plot_id}", status_code=204)
async def delete_plot(
    plot_id: UUID,
    current_user=Depends(require_roles(UserRole.ADMINISTRATOR, UserRole.MANAGER)),
    db: AsyncSession = Depends(get_db),
):
    """Delete a plot (Administrator/Manager); blocked (409) while a record is linked."""
    service = PlotService(db)
    await service.delete(plot_id, current_user.tenant_id)


@router.patch("/{plot_id}/status", response_model=dict)
async def change_plot_status(
    plot_id: UUID,
    body: ChangePlotStatusRequest,
    current_user=Depends(
        require_roles(
            UserRole.ADMINISTRATOR,
            UserRole.MANAGER,
            UserRole.STAFF,
        )
    ),
    db: AsyncSession = Depends(get_db),
):
    """Change the status of a plot (vacant / reserved / occupied / hold)."""
    service = PlotService(db)
    plot = await service.change_status(
        plot_id=plot_id,
        tenant_id=current_user.tenant_id,
        status=body.status,
    )
    return success(
        data=_serialize_plot(plot),
        message=f"Plot status updated to '{plot.status}'",
    )


# ── INDL-42 — Revert to vacant ───────────────────────────────────────────────


@router.patch("/{plot_id}/revert", response_model=dict)
async def revert_plot(
    plot_id: UUID,
    current_user=Depends(require_roles(UserRole.ADMINISTRATOR, UserRole.MANAGER)),
    db: AsyncSession = Depends(get_db),
):
    """Revert an occupied/reserved plot to the tenant default (vacant) status and
    clear its reservation fields (AC-18). Administrator/Manager only; a plot id
    from another tenant returns 404, never a cross-tenant mutation (SEC-02)."""
    service = PlotService(db)
    plot, prior_status = await service.revert_to_default(plot_id, current_user.tenant_id)
    # Structured audit trail (SEC-11 / V7.2): actor, tenant, plot, prior status.
    logger.info(
        "audit plots.revert actor_id=%s tenant_id=%s plot_id=%s prior_status=%s new_status=%s",
        current_user.id, current_user.tenant_id, plot_id, prior_status, plot.status,
    )
    return success(data=_serialize_plot(plot), message="Plot reverted to vacant")


# ── INDL-42 — Bulk CSV / Excel import ────────────────────────────────────────


@router.post("/import/suggest-mapping", response_model=dict)
async def suggest_import_mapping(
    body: SuggestMappingRequest,
    current_user=Depends(require_roles(UserRole.ADMINISTRATOR, UserRole.MANAGER)),
):
    """Heuristic source-column → INDELIS-field mapping for the wizard's Step 2."""
    return success(data={"mapping": suggest_mapping(body.columns)})


@router.post("/import/preview", response_model=dict)
async def preview_import(
    file: UploadFile = File(...),
    current_user=Depends(require_roles(UserRole.ADMINISTRATOR, UserRole.MANAGER)),
):
    """Step 1/2: parse the upload server-side and return columns, a small sample,
    the row count, and a suggested column mapping. The file is never persisted."""
    content = await _read_upload(file)
    columns, rows = parse_file(file.filename or "", content)
    return success(data={
        "columns": columns,
        "row_count": len(rows),
        "column_count": len(columns),
        "sample_rows": rows[:5],
        "suggested_mapping": suggest_mapping(columns),
    })


@router.post("/import", response_model=dict)
async def import_plots(
    file: UploadFile = File(...),
    mapping: str = Form(...),
    current_user=Depends(require_roles(UserRole.ADMINISTRATOR, UserRole.MANAGER)),
    db: AsyncSession = Depends(get_db),
):
    """Step 4: bulk-create plots from mapped rows. The whole body is re-validated
    server-side (A07); imported plots always get geometry = NULL (point markers).

    `mapping` is a JSON object of {source_column: target_field}.
    """
    await _enforce_import_rate_limit(current_user.tenant_id)
    content = await _read_upload(file)
    try:
        mapping_dict = json.loads(mapping)
        if not isinstance(mapping_dict, dict):
            raise ValueError
    except (ValueError, TypeError):
        from src.core.exceptions import ValidationError
        raise ValidationError("`mapping` must be a JSON object of column → field")

    columns, rows = parse_file(file.filename or "", content)
    service = PlotImportService(db)
    result = await service.import_rows(current_user.tenant_id, rows, mapping_dict)
    result["error_report_csv"] = (
        service.build_error_report_csv(result["failed"]) if result["failed"] else None
    )
    logger.info(
        "audit plots.import actor_id=%s tenant_id=%s created=%s skipped=%s total=%s",
        current_user.id, current_user.tenant_id,
        result["created"], result["skipped"], result["total"],
    )
    return success(
        data=result,
        message=f"Import complete — {result['created']} created, {result['skipped']} skipped",
    )
