import asyncio
import json
import os
import re

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from pydantic import BaseModel

STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
CONFIG_FILE = os.path.join(os.path.dirname(__file__), "config.json")
INDEX_FILE = os.path.join(STATIC_DIR, "index.html")

# Matches local CSS/JS asset references in index.html, e.g.
#   href="css/style.css"   src="js/app.js"
_ASSET_REF_RE = re.compile(r'((?:href|src)=")((?:css|js)/[^"]+\.(?:css|js))(")')


def _compute_asset_version():
    """A cache-busting token derived from the newest static file mtime.

    The locked-down HMI browsers (e.g. Chromium 119 on the Weidmüller panel)
    ignore Cache-Control: no-store, so only a changing URL reliably forces a
    fresh fetch of CSS/JS. Deriving the token from file mtimes means it changes
    exactly when the assets change (i.e. on a deploy, which also restarts the
    app), so the panel picks up new files without any manual version bumping.
    """
    latest = 0.0
    for root, _dirs, files in os.walk(STATIC_DIR):
        for name in files:
            try:
                mtime = os.path.getmtime(os.path.join(root, name))
                if mtime > latest:
                    latest = mtime
            except OSError:
                pass
    return str(int(latest))


_ASSET_VERSION = _compute_asset_version()


def _render_index():
    """Reads index.html and appends ?v=<token> to its CSS/JS asset URLs."""
    with open(INDEX_FILE, "r") as f:
        html = f.read()
    return _ASSET_REF_RE.sub(
        lambda m: m.group(1) + m.group(2) + "?v=" + _ASSET_VERSION + m.group(3),
        html,
    )


class ProgramRequest(BaseModel):
    program: str


class VelocityRequest(BaseModel):
    velocity: float


class JogJointRequest(BaseModel):
    axis: int
    delta: float


class JogCartRequest(BaseModel):
    axis: str
    delta: float


def _robot_action(action):
    """Executes a robot action and returns a JSON response with error handling."""
    try:
        result = action()
        return JSONResponse(result if isinstance(result, dict) else {"ok": True})
    except Exception as e:
        return JSONResponse({"ok": False, "error": str(e)}, status_code=500)


def create_app(operator_hmi_app):
    """Creates the FastAPI application wired to the given OperatorHMIApp instance."""
    api = FastAPI()

    # --- REST endpoints ---

    @api.get("/api/state")
    def get_state():
        return JSONResponse(operator_hmi_app.GetLatestState())

    @api.get("/api/programs")
    def get_programs():
        try:
            result = operator_hmi_app.ListFiles("Programs")
            if result.success:
                files = [e.name for e in result.entries if e.name.endswith(".xml")]
                return JSONResponse({"programs": files})
            return JSONResponse(
                {"programs": [], "error": result.errorMessage},
                status_code=502,
            )
        except Exception as e:
            return JSONResponse(
                {"programs": [], "error": str(e)}, status_code=502
            )

    @api.get("/api/config")
    def get_config():
        try:
            with open(CONFIG_FILE, "r") as f:
                return JSONResponse(json.load(f))
        except FileNotFoundError:
            return JSONResponse({"tiles": {"sidebar": []}})

    @api.get("/api/error-codes")
    def get_error_codes():
        return JSONResponse(operator_hmi_app._errorCodes)

    @api.post("/api/enable")
    def enable_motors():
        # Reset errors before enabling, mirroring iRC behavior: right after
        # startup the axes sit in an error state and the UI only offers the
        # Enable button (Reset is the same toggle, shown only once enabled), so
        # enabling alone would leave the error standing.
        def action():
            operator_hmi_app.ResetErrors()
            operator_hmi_app.EnableMotors()
        return _robot_action(action)

    @api.post("/api/disable")
    def disable_motors():
        return _robot_action(operator_hmi_app.DisableMotors)

    @api.post("/api/reset")
    def reset_errors():
        return _robot_action(operator_hmi_app.ResetErrors)

    @api.post("/api/reference")
    def reference_all():
        return _robot_action(lambda: operator_hmi_app.ReferenceAllJoints(False))

    @api.post("/api/program/load")
    def load_program(req: ProgramRequest):
        return _robot_action(lambda: operator_hmi_app.LoadMotionProgram(req.program))

    @api.post("/api/program/start")
    def start_program():
        return _robot_action(operator_hmi_app.StartMotionProgram)

    @api.post("/api/program/pause")
    def pause_program():
        return _robot_action(operator_hmi_app.PauseMotionProgram)

    @api.post("/api/program/stop")
    def stop_program():
        return _robot_action(operator_hmi_app.StopMotionProgram)

    @api.post("/api/jog/joint-relative")
    def jog_joint_relative(req: JogJointRequest):
        def action():
            joints = [0.0] * 9  # a1-a6 + e1-e3
            if 0 <= req.axis < 9:
                joints[req.axis] = req.delta
            vel = operator_hmi_app.GetVelocityOverride()
            operator_hmi_app.MoveToJointRelative(
                vel, -1,
                joints[0], joints[1], joints[2],
                joints[3], joints[4], joints[5],
                joints[6], joints[7], joints[8],
            )
        return _robot_action(action)

    @api.post("/api/jog/cart-relative")
    def jog_cart_relative(req: JogCartRequest):
        def action():
            vals = {"x": 0.0, "y": 0.0, "z": 0.0, "a": 0.0, "b": 0.0, "c": 0.0}
            if req.axis in vals:
                vals[req.axis] = req.delta
            vel = operator_hmi_app.GetVelocityOverride()
            operator_hmi_app.MoveToLinearRelativeBase(
                vel, -1,
                vals["x"], vals["y"], vals["z"],
                vals["a"], vals["b"], vals["c"],
                0, 0, 0, "",
            )
        return _robot_action(action)

    @api.post("/api/jog/stop")
    def jog_stop():
        return _robot_action(operator_hmi_app.MoveToStop)

    @api.post("/api/velocity")
    def set_velocity(req: VelocityRequest):
        def action():
            clamped = max(0, min(100, req.velocity))
            operator_hmi_app.SetVelocityOverride(clamped)
            return {"ok": True, "velocity": clamped}
        return _robot_action(action)

    # --- WebSocket ---

    @api.websocket("/ws")
    async def websocket_endpoint(ws: WebSocket):
        await ws.accept()
        try:
            while True:
                state = operator_hmi_app.GetLatestState()
                state["grpcConnected"] = operator_hmi_app.IsConnected()
                await ws.send_json(state)
                await asyncio.sleep(0.2)
        except (WebSocketDisconnect, Exception):
            pass

    # --- Static files ---

    # Only the HTML document is served uncached, so it always reflects the
    # current asset version (see _render_index). The assets themselves are
    # cached normally: CSS/JS are busted via their ?v=<token> URL when they
    # change, and icons/fonts keep their URL so they stay cached. Sending
    # no-store on the assets instead caused the panel to re-fetch the button
    # icons on every state tick (their src is reassigned every 200ms), so it
    # is deliberately not applied here.
    _NO_CACHE = {"Cache-Control": "no-store"}

    @api.get("/")
    def root():
        # Serve index.html with auto-versioned asset URLs so the panel always
        # loads the current CSS/JS even when its browser ignores no-store.
        return HTMLResponse(_render_index(), headers=_NO_CACHE)

    @api.get("/{path:path}")
    def static_files(path: str):
        file_path = os.path.realpath(os.path.join(STATIC_DIR, path))
        if not file_path.startswith(os.path.realpath(STATIC_DIR)):
            return JSONResponse({"error": "Forbidden"}, status_code=403)
        if os.path.isfile(file_path):
            return FileResponse(file_path)
        return JSONResponse({"error": "Not found"}, status_code=404)

    return api
