import asyncio
import logging
import shutil
from contextlib import asynccontextmanager
from importlib import metadata
from pathlib import Path
from tempfile import gettempdir
from uuid import UUID

import pydantic
import pydantic_core
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import FileResponse
from nicegui import app, ui
from starlette.applications import Starlette

from xmas_app.deps.version_guard import enforce_plugin_version
from xmas_app.form import ModelForm
from xmas_app.models.crud import InsertPayload, UpdatePayload
from xmas_app.models.split import (
    ErrorDetail,
    ErrorResponse,
    SplitPayload,
    SplitSuccess,
)
from xmas_app.pages import PlanManagerPage
from xmas_app.pages.plantools import PlanToolsPage
from xmas_app.services import crud
from xmas_app.services.document import get_document_from_db
from xmas_app.services.split import PlanSplitService, SplitValidationError
from xmas_app.settings import get_settings, initialize_db
from xmas_app.util import get_model_for_select

__version__ = metadata.version("xmas_app")


def _resolve_log_dir() -> Path:
    if get_settings().app_mode == "prod":
        return Path(gettempdir()) / "xmas_log"
    else:
        # dev: local repo logs
        return Path(__file__).parent.parent


log_dir = _resolve_log_dir()
log_dir.mkdir(exist_ok=True)
log_file = log_dir / "xmas_app.log"

logger = logging.getLogger("xmas_app")
logger.propagate = False

logger.handlers.clear()
logger.setLevel(logging.DEBUG if get_settings().debug else logging.INFO)
fh = logging.FileHandler(log_file, mode="w", encoding="utf-8")
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s")
fh.setFormatter(formatter)
logger.addHandler(fh)
logger.debug(f"Writing logs to {log_file}")

ui.element.default_props("dense")

router = APIRouter()

app.on_startup(initialize_db)
app.on_shutdown(lambda: shutil.rmtree(get_settings().static_file_dir))


@ui.page(
    "/plan-tools/{id}",
    reconnect_timeout=5,
    dependencies=[Depends(enforce_plugin_version)],
)
async def plan_tools(
    request: Request,
    id: UUID,
):
    """Render the PlanTools page.

    Args:
        request: Incoming FastAPI HTTP request to check the user-agent header.
        id: The ID of the plan.
    """
    await PlanToolsPage(request=request, plan_id=str(id)).render()


@ui.page("/plans", dependencies=[Depends(enforce_plugin_version)])
async def plans(
    request: Request,
    appschema: str = get_settings().appschema,
    version: str = get_settings().appschema_version,
):
    """Render the PlanManager page.

    Args:
        request: Incoming FastAPI HTTP request to check the user-agent header.
        appschema (str): Application schema name (default from settings).
        version (str): Schema version (default from settings).
    """
    await PlanManagerPage(
        request=request, appschema=appschema, version=version
    ).render()


@ui.page("/feature/{id}", dependencies=[Depends(enforce_plugin_version)])
async def feature(
    request: Request,
    id: UUID,
    planId: UUID | None = None,
    parentId: UUID | None = None,
    featureType: str | None = None,
    featuretypeRegex: str | None = None,
    appschema: str = get_settings().appschema,
    version: str = get_settings().appschema_version,
):
    """Render and manage a single feature view for the XPlan-GUI application.

    Args:
        request (Request): The incoming FastAPI HTTP request object.
        id (str): UUID or identifier of the feature to display/edit.
        planId (str | None): Optional ID of the associated plan.
        parentId (str | None): Optional ID of the parent feature.
        featureType (str | None): Optional fixed feature type to display.
        featuretypeRegex (str | None): Optional regex to filter allowed feature types.
        appschema (str): The application schema name (defaults from settings).
        version (str): Schema version string (defaults from settings).
    """
    id = str(id)

    async def get_qgis_feature():
        return await ui.run_javascript(
            """return await new Promise((resolve, reject) => {
                    new QWebChannel(qt.webChannelTransport, function (channel) {
                        channel.objects.handler.transfer_feature().then(data => data ? resolve(data) : reject())
                    })
                });""",
            timeout=3,
        )

    async def add_form(
        feature_type: str,
        feature: dict,
    ) -> None:
        content.set_visibility(False)
        spinner.set_visibility(True)
        with content:
            await asyncio.sleep(0.1)  # let spinner transfer state to client
            await app.storage.client["form"].render_form(feature_type, feature)
            if get_settings().debug:
                with ui.row().classes("w-full"):
                    obj_log = ui.log()
                    ui.button(
                        "show model",
                        on_click=lambda: (
                            obj_log.clear(),
                            obj_log.push(
                                model.model_dump_json(indent=2)
                                if (model := app.storage.client["form"].model_instance)
                                else "No Model",
                            ),
                        ),
                    )
                    ui.button(
                        "show submodels",
                        on_click=lambda: (
                            obj_log.clear(),
                            obj_log.push(
                                "\n".join(
                                    model.model_dump_json()
                                    for model in app.storage.client[
                                        "form"
                                    ].sub_models.values()
                                )
                            ),
                        ),
                    )
        spinner.delete()
        content.set_visibility(True)

    def handle_save():
        form: ModelForm = app.storage.client["form"]
        feature = form.model_instance
        if feature:
            feature.id = id
            get_settings().repo.update(feature)
            ui.navigate.to(f"/?saved_feature_uuid={feature.id}")

    def handle_delete():
        form: ModelForm = app.storage.client["form"]
        feature = form.model_instance
        if feature:
            get_settings().repo.delete(id)
            ui.navigate.to("/")

    def on_edit_mode_changed(e):
        if form := app.storage.client["form"]:
            form.editable = e.args

    ui.colors(primary="rgb(157 157 156)", secondary="rgb(57 92 127)")
    app.storage.client["form"] = None
    app.storage.client["plan_id"] = str(planId) if planId else None
    app.storage.client["parent_id"] = str(parentId) if parentId else None
    app.storage.client["user_agent"] = request.headers.get("user-agent", None)

    qgis = app.storage.client["user_agent"].startswith(get_settings().qgis_plugin_name)

    if qgis:
        ui.add_head_html('<script src="qrc:///qtwebchannel/qwebchannel.js"></script>')

    try:
        model = get_settings().repo.get(id)
        feature_type = model.get_name()
        feature = model.model_dump(
            mode="json", context={"datatype": True, "file_uri": True}
        )
    except ValueError:
        feature = {"id": id}
        feature_type = featureType

    ui.on("editModeChanged", on_edit_mode_changed)

    header = ui.header()  # .classes("bg-white text-black")

    if not qgis:
        with header:
            with ui.column(align_items="center").bind_visibility_from(
                app.storage.client,
                "form",
                backward=lambda form: getattr(form, "rendered", False),
            ):
                with ui.row(align_items="center"):
                    ui.button(
                        text="Speichern",
                        icon="save",
                        on_click=handle_save,
                    ).bind_enabled_from(app.storage.client, "form").props(
                        "unelevated rounded no-caps"
                    )
                    ui.button(
                        text="Löschen",
                        icon="delete",
                        on_click=handle_delete,
                    ).bind_enabled_from(app.storage.client, "form").props(
                        "unelevated rounded no-caps"
                    )
                    ui.button(
                        text="Abbrechen",
                        icon="cancel",
                        on_click=lambda: ui.navigate.to("/"),
                    ).props("unelevated rounded no-caps")
    spinner = ui.spinner(size="xl").classes("absolute-center")
    with ui.grid(columns=2 if get_settings().debug else 1).classes(
        "justify-center items-start"
    ) as content:
        with ui.column(align_items="center").classes(
            "justify-center"
        ):  # .classes("absolute-center"):
            with ui.row(align_items="center").classes("col-grow") as sel:
                ui.label("Objektart auswählen").classes("text-h6")
                model_select = (
                    ui.select(
                        options=[],
                        value=None,
                        label="Objektarten",
                        on_change=lambda x: add_form(x.value, feature),
                        with_input=True,
                    )
                    .props("square filled options-dense")
                    .style("width: 500px;")
                )
                # .classes("absolute-center")
            if feature_type:
                sel.set_visibility(False)
            else:
                spinner.set_visibility(False)
        app.storage.client["form"] = ModelForm(appschema, version, content, header)
        await ui.context.client.connected(
            timeout=10
        )  # see https://nicegui.io/documentation/page#wait_for_client_connection
        if qgis:
            qgis_feature = await get_qgis_feature()
            if isinstance(properties := qgis_feature.get("properties"), dict):
                feature = feature | properties
            if isinstance(geometry := qgis_feature.get("geometry"), dict):
                feature["geometry"] = geometry
        if feature_type:
            await add_form(feature_type, feature)
        else:
            await get_model_for_select(
                model_select,
                qgis_feature.get("geometry", {}).get("wkt", None) if qgis else None,
                featuretypeRegex,
                appschema,
                version,
            )


@app.get("/health_check")
def health_check() -> dict:
    """Health check endpoint to verify that the service is running.

    Returns:
        dict: A simple dictionary with the app's name and version.
    """
    return {"name": "XMAS-App", "version": __version__}


@app.api_route("/plan/{plan_id}/documents/{filename}", methods=["GET", "HEAD"])
async def get_document_data(plan_id: UUID, filename: str) -> FileResponse:
    """Returns the data of a document."""
    response = await get_document_from_db(str(plan_id), filename)
    return response


@app.get("/feature/{id}/properties", dependencies=[Depends(enforce_plugin_version)])
async def get_feature_data(id: UUID) -> dict:
    """Returns the data of a feature."""
    feature = get_settings().repo.get(str(id))
    excludes = {"id"}
    if geom_field := feature.get_geom_field():
        excludes.add(geom_field)
    return feature.model_dump(mode="json", exclude=excludes, exclude_unset=True)


@app.post(
    "/insert-features", status_code=201, dependencies=[Depends(enforce_plugin_version)]
)
async def insert_features(payload: InsertPayload, planId: UUID):
    """Insert a number of features."""
    await crud.create(payload, str(planId))


@app.post(
    "/update-features", status_code=204, dependencies=[Depends(enforce_plugin_version)]
)
async def update_features(payload: UpdatePayload):
    """Update a number of features."""
    await crud.update(payload)


@app.post(
    "/split-tool",
    response_model=SplitSuccess,
    status_code=status.HTTP_201_CREATED,  # semantically 'created'
    responses={
        400: {"model": ErrorResponse, "description": "Bad Request"},
        422: {"model": ErrorResponse, "description": "Validation Error"},
        500: {"model": ErrorResponse, "description": "Server Error"},
    },
    dependencies=[Depends(enforce_plugin_version)],
)
async def receive_split_plans(payload: SplitPayload) -> SplitSuccess:
    logger.info("Split plans endpoint reached.")

    API_VERSION = "split-import-v1-min"

    # Lightweight contract check up front
    if payload.schema_version != API_VERSION:
        logger.error("Payload api schema version incorrect.")
        raise HTTPException(
            status_code=400,
            detail=ErrorDetail(
                code="invalid_schema_version", message="Invalid schema_version"
            ).model_dump(),
        )

    # Early existence check to short-circuit before the service
    try:
        source_plan = get_settings().repo.get_plan_by_id(payload.src_plan_id)
        if source_plan is None:
            logger.error("Couldn't load plan from database with provided id.")
            raise HTTPException(
                status_code=400,
                detail=ErrorDetail(
                    code="plan_not_found", message="Plan not found"
                ).model_dump(),
            )
    except (pydantic.ValidationError, pydantic_core.ValidationError) as e:
        logger.error(f"Plan validation failed: {e}")
        raise HTTPException(
            status_code=422,
            detail=ErrorDetail(code="validation_error", message=str(e)).model_dump(),
        )

    service = PlanSplitService(get_settings().repo)

    try:
        return service.apply_split_import(payload, source_plan)
    except SplitValidationError as e:
        logger.warning("Split validation failed with %d violations", len(e.violations))
        raise HTTPException(
            status_code=400,
            detail=ErrorDetail(
                code="validation_failed",
                message=e.message,
                violations=[
                    {
                        "code": v.code,
                        "featuretype": v.featuretype,
                        "old_id": v.old_id,
                        "hint": v.hint,
                    }
                    for v in e.violations
                ],
            ).model_dump(),
        )
    except ValueError as e:
        logger.error("Domain error during split: %s", e)
        raise HTTPException(
            status_code=400,
            detail=ErrorDetail(code="domain_error", message=str(e)).model_dump(),
        )
    except Exception as e:
        logger.exception(
            "Unhandled split processing error. "
            "Likely dangling association reference. "
            "Check for edges whose dst_old not in id_map."
        )
        raise HTTPException(
            status_code=500,
            detail=ErrorDetail(
                code="server_error", message=f"Processing error: {e}"
            ).model_dump(),
        ) from e


@asynccontextmanager
async def lifespan(starlette_app):
    msg = f"XMAS-App is running on port {get_settings().app_port} in {get_settings().app_mode} mode."
    logger.info(msg)
    print(msg)
    yield
    logger.info("XMAS-App is shutting down.")
    print("XMAS-App is shutting down.")


starlette_app = Starlette(
    debug=get_settings().debug,
    routes=None,
    lifespan=lifespan,
)
ui.run_with(starlette_app)


def create_app() -> Starlette:
    """Factory for the plugin: return the ASGI app, without asyncio or uvicorn."""

    # os.environ["PYGEOAPI_CONFIG"] = str(Path(__file__).parent / "pygeoapi" / "config.yaml")
    # os.environ["PYGEOAPI_OPENAPI"] = str(
    #     Path(__file__).parent / "pygeoapi" / "openapi.yaml"
    # )
    # from pygeoapi.starlette_app import APP as pygeoapi_app

    # starlette.mount("/oapi", pygeoapi_app)

    return starlette_app


def run_server():
    import uvicorn

    print("Starting server in stand alone mode.")
    logger.info("Starting server in stand alone mode.")

    try:
        uvicorn.run(
            "xmas_app.main:starlette_app",
            host="0.0.0.0",
            port=get_settings().app_port,
            reload=get_settings().app_mode == "dev",
            log_config=None,  # avoids isatty() issue
        )
    except Exception as e:
        msg = f"Failed to start server: {e}"
        print(msg)
        logger.error(msg)


if __name__ == "__main__":
    run_server()
