from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional, List
import time
import threading
import json
import os
import hmac

try:
    import lgpio
except ImportError:
    lgpio = None

app = FastAPI(title="RoboPark Motor Control API", version="1.2.0")

ROBOT_NAME = os.getenv("ROBOPARK_ROBOT_NAME", "robot").strip() or "robot"

MOTOR_API_TOKEN = (
    os.getenv("ROBOPARK_MOTOR_TOKEN", "").strip()
    or os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
)


@app.middleware("http")
async def require_motor_token(request: Request, call_next):
    """Keep actuation private even if an operator accidentally changes the bind host."""
    if MOTOR_API_TOKEN and request.url.path not in {"/", "/status"}:
        supplied = request.headers.get("x-robopark-motor-token", "").strip()
        if not supplied or not hmac.compare_digest(supplied, MOTOR_API_TOKEN):
            return JSONResponse(status_code=401, content={"detail": "Invalid or missing motor token"})
    return await call_next(request)

# The dashboard never calls this service directly; robot-local services do.
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://127.0.0.1", "http://localhost"],
    allow_credentials=False,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["content-type", "x-robopark-motor-token"],
)

# Storage file
MOTORS_FILE = os.getenv(
    "ROBOPARK_MOTORS_FILE",
    os.path.expanduser("~/.robopark/motors.json"),
)

# In-memory storage
motors = {}
active_motor = None
remaining_seconds = 0
last_action = "None"
motor_thread = None
stop_flag = False
motor_error = None
gpio_handle = None
claimed_pins = set()
motor_lock = threading.Lock()

# Load motors from file on startup
def load_motors():
    """Load motors from motors.json file"""
    global motors
    try:
        if os.path.exists(MOTORS_FILE):
            with open(MOTORS_FILE, 'r') as f:
                motors = json.load(f)
            motors = {
                str(name): ({"name": str(name), "gpio": int(value), "active_high": True}
                            if not isinstance(value, dict) else {
                                "name": str(value.get("name") or name), "gpio": int(value["gpio"]),
                                "active_high": bool(value.get("active_high", True)),
                            })
                for name, value in motors.items()
            }
            print(f"[LOAD] Loaded {len(motors)} motors from {MOTORS_FILE}")
            for name, gpio in motors.items():
                print(f"  - {name}: GPIO {gpio}")
        else:
            print(f"[LOAD] No {MOTORS_FILE} found, starting with empty motors")
    except Exception as e:
        print(f"[ERROR] Failed to load motors: {e}")
        motors = {}

def save_motors():
    """Save motors to motors.json file"""
    try:
        os.makedirs(os.path.dirname(os.path.abspath(MOTORS_FILE)), exist_ok=True)
        temporary = f"{MOTORS_FILE}.tmp"
        with open(temporary, 'w') as f:
            json.dump(motors, f, indent=2)
            f.flush()
            os.fsync(f.fileno())
        os.replace(temporary, MOTORS_FILE)
        print(f"[SAVE] Motors saved to {MOTORS_FILE}")
    except Exception as e:
        print(f"[ERROR] Failed to save motors: {e}")

# Load motors on startup
load_motors()

# Request Models
class MotorConfig(BaseModel):
    name: str
    gpio: int
    active_high: bool = True

class UpdateMotorConfig(BaseModel):
    name: str
    gpio: int
    active_high: bool = True

class TriggerMotor(BaseModel):
    motor_name: str
    seconds: float = 1.0

class StopMotors(BaseModel):
    motor_name: Optional[str] = None

class TestConfig(BaseModel):
    seconds_on: float = 0.3
    seconds_pause: float = 0.2
    pins: Optional[List[int]] = None

# Helper Functions
def _motor_value(motor: dict, active: bool) -> int:
    return int(active == bool(motor.get("active_high", True)))

def _claim_motor(motor: dict) -> None:
    global gpio_handle
    pin = int(motor["gpio"])
    if pin < 2 or pin > 27:
        raise ValueError(f"BCM GPIO {pin} is outside 2..27")
    if lgpio is None:
        raise RuntimeError("lgpio is not installed; refusing simulated production actuation")
    if gpio_handle is None:
        gpio_handle = lgpio.gpiochip_open(0)
    if pin not in claimed_pins:
        lgpio.gpio_claim_output(gpio_handle, pin, _motor_value(motor, False))
        claimed_pins.add(pin)

def _write_motor(motor: dict, active: bool) -> None:
    _claim_motor(motor)
    lgpio.gpio_write(gpio_handle, int(motor["gpio"]), _motor_value(motor, active))

def _all_off() -> None:
    for motor in motors.values():
        try:
            if isinstance(motor, dict):
                _write_motor(motor, False)
        except Exception as exc:
            print(f"[GPIO] failed to de-energize {motor}: {exc}")

def run_motor_thread(motor_name: str, duration: float):
    global active_motor, remaining_seconds, last_action, stop_flag, motor_error

    motor = motors[motor_name]
    started = time.monotonic()
    print(f"[MOTOR] Starting {motor_name} for {duration:.3f} seconds")
    try:
        _write_motor(motor, True)
        while not stop_flag and time.monotonic() - started < duration:
            remaining_seconds = max(0, duration - (time.monotonic() - started))
            time.sleep(min(0.05, remaining_seconds or 0.01))
    except Exception as exc:
        motor_error = f"{type(exc).__name__}: {exc}"
        last_action = f"Failed {motor_name}: {motor_error}"
        print(f"[ERROR] Motor {motor_name} failed: {motor_error}")
    finally:
        try:
            _write_motor(motor, False)
        finally:
            with motor_lock:
                active_motor = None
                remaining_seconds = 0
                stop_flag = False
            print(f"[MOTOR] {motor_name} completed and GPIO is OFF")

@app.on_event("shutdown")
def shutdown_gpio() -> None:
    global gpio_handle
    _all_off()
    if lgpio is not None and gpio_handle is not None:
        try:
            lgpio.gpiochip_close(gpio_handle)
        finally:
            gpio_handle = None
            claimed_pins.clear()

# API Endpoints
@app.get("/")
async def root():
    return {
        "message": "Motor Control API",
        "version": "1.2.0",
        "robot": ROBOT_NAME,
        "authentication": "token" if MOTOR_API_TOKEN else "localhost-only",
        "endpoints": ["/status", "/test", "/add-motor", "/trigger-motor", "/list-motors", "/stop-motors"]
    }

@app.get("/status")
async def get_status():
    status = "running" if active_motor else "idle"
    return {
        "status": status,
        "active_motor": active_motor,
        "remaining_seconds": remaining_seconds,
        "last_action": last_action,
        "error": motor_error,
        "total_motors": len(motors),
        "robot": ROBOT_NAME,
        "gpio_available": lgpio is not None,
    }

@app.post("/add-motor")
async def add_motor(config: MotorConfig):
    if config.gpio < 2 or config.gpio > 27:
        raise HTTPException(status_code=422, detail="BCM GPIO must be in range 2..27")
    if config.name in motors:
        raise HTTPException(status_code=400, detail=f"Motor '{config.name}' already exists")
    if any(int(item["gpio"]) == config.gpio for item in motors.values()):
        raise HTTPException(status_code=409, detail=f"BCM GPIO {config.gpio} is already registered")

    motors[config.name] = {
        "name": config.name,
        "gpio": config.gpio,
        "active_high": config.active_high,
    }
    save_motors()

    print(f"[MOTOR] Added motor: {config.name} on GPIO {config.gpio}")

    return {
        "status": "success",
        "message": f"Motor '{config.name}' added successfully",
        "motor": motors[config.name]
    }

@app.get("/list-motors")
async def list_motors():
    motor_list = list(motors.values())
    return {
        "status": "success",
        "count": len(motor_list),
        "motors": motor_list
    }

@app.get("/discover")
async def discover_motors():
    """Return the relay registry and GPIO capability for fleet discovery."""
    motor_list = list(motors.values())
    return {
        "status": "success",
        "robot": ROBOT_NAME,
        "gpio_available": lgpio is not None,
        "count": len(motor_list),
        "motors": motor_list,
    }

@app.put("/update-motor/{motor_name}")
async def update_motor(motor_name: str, config: UpdateMotorConfig):
    if config.gpio < 2 or config.gpio > 27:
        raise HTTPException(status_code=422, detail="BCM GPIO must be in range 2..27")
    if motor_name not in motors:
        raise HTTPException(status_code=404, detail=f"Motor '{motor_name}' not found")

    if active_motor == motor_name:
        raise HTTPException(status_code=409, detail=f"Cannot update motor '{motor_name}' while it is running")

    if config.name != motor_name and config.name in motors:
        raise HTTPException(status_code=400, detail=f"Motor name '{config.name}' already exists")
    if any(name != motor_name and int(item["gpio"]) == config.gpio for name, item in motors.items()):
        raise HTTPException(status_code=409, detail=f"BCM GPIO {config.gpio} is already registered")

    if config.name != motor_name:
        del motors[motor_name]

    motors[config.name] = {
        "name": config.name,
        "gpio": config.gpio,
        "active_high": config.active_high,
    }
    save_motors()

    print(f"[MOTOR] Updated motor: {motor_name} -> {config.name} on GPIO {config.gpio}")

    return {
        "status": "success",
        "message": f"Motor updated successfully",
        "motor": motors[config.name]
    }

@app.delete("/delete-motor/{motor_name}")
async def delete_motor(motor_name: str):
    if motor_name not in motors:
        raise HTTPException(status_code=404, detail=f"Motor '{motor_name}' not found")

    if active_motor == motor_name:
        raise HTTPException(status_code=409, detail=f"Cannot delete motor '{motor_name}' while it is running")

    deleted_motor = motors.pop(motor_name)
    save_motors()

    print(f"[MOTOR] Deleted motor: {motor_name}")

    return {
        "status": "success",
        "message": f"Motor '{motor_name}' deleted successfully",
        "motor": deleted_motor
    }

@app.post("/trigger-motor")
async def trigger_motor(config: TriggerMotor):
    global motor_thread, stop_flag, active_motor, remaining_seconds, last_action, motor_error

    if config.motor_name not in motors:
        raise HTTPException(status_code=404, detail=f"Motor '{config.motor_name}' not found")

    if config.seconds < 0.05 or config.seconds > 10.0:
        raise HTTPException(status_code=422, detail="Pulse duration must be 0.05..10 seconds")

    try:
        _claim_motor(motors[config.motor_name])
    except Exception as exc:
        raise HTTPException(status_code=503, detail=f"GPIO is unavailable: {exc}") from exc

    with motor_lock:
        if active_motor:
            raise HTTPException(status_code=409, detail=f"Motor '{active_motor}' is already running")
        stop_flag = False
        motor_error = None
        active_motor = config.motor_name
        remaining_seconds = config.seconds
        last_action = f"Triggered {config.motor_name}"
        motor_thread = threading.Thread(target=run_motor_thread, args=(config.motor_name, config.seconds), daemon=True)
        motor_thread.start()

    return {
        "status": "success",
        "message": f"Motor '{config.motor_name}' triggered for {config.seconds} seconds",
        "motor_name": config.motor_name,
        "duration": config.seconds
    }

@app.post("/stop-motors")
async def stop_motors(config: Optional[StopMotors] = None):
    global stop_flag, last_action

    try:
        if not active_motor:
            _all_off()
            return {
                "status": "success",
                "message": "No motors are currently running"
            }

        stop_flag = True
        last_action = f"Stopped {active_motor}"

        # Wait for thread to finish
        if motor_thread and motor_thread.is_alive():
            motor_thread.join(timeout=2)
        _all_off()

        return {
            "status": "success",
            "message": "All motors stopped",
            "stopped_motor": active_motor
        }
    except Exception as e:
        # Handle GPIO busy or other errors gracefully
        print(f"[ERROR] Stop motors failed: {e}")
        stop_flag = True
        return {
            "status": "error",
            "message": f"Error stopping motors: {str(e)}",
            "detail": str(e)
        }

@app.post("/test")
async def run_test(config: TestConfig):
    global last_action, active_motor, motor_error

    if config.seconds_on < 0.05 or config.seconds_on > 10.0:
        raise HTTPException(status_code=422, detail="Test pulse duration must be 0.05..10 seconds")
    if config.seconds_pause < 0 or config.seconds_pause > 10.0:
        raise HTTPException(status_code=422, detail="Test pause must be 0..10 seconds")

    registered_pins = {int(m["gpio"]) for m in motors.values() if isinstance(m, dict)}
    pins = config.pins if config.pins else sorted(registered_pins)
    if not pins or any(pin not in registered_pins for pin in pins):
        raise HTTPException(status_code=422, detail="Relay tests are limited to registered GPIO pins")
    try:
        for motor in motors.values():
            if int(motor["gpio"]) in pins:
                _claim_motor(motor)
    except Exception as exc:
        raise HTTPException(status_code=503, detail=f"GPIO is unavailable: {exc}") from exc

    with motor_lock:
        if active_motor:
            raise HTTPException(status_code=409, detail="A motor is already running")
        active_motor = "relay-test"
        motor_error = None

    print(f"[TEST] Running test sequence:")
    print(f"  - Seconds ON: {config.seconds_on}")
    print(f"  - Seconds Pause: {config.seconds_pause}")
    print(f"  - Pins: {pins}")

    last_action = f"Test sequence started"

    def test_sequence():
        global last_action, active_motor, motor_error
        try:
            for pin in pins:
                last_action = f"test:gpio:{pin}"
                motor = next(m for m in motors.values() if int(m["gpio"]) == pin)
                print(f">>> TESTING GPIO {pin}")
                try:
                    _write_motor(motor, True)
                    time.sleep(config.seconds_on)
                finally:
                    _write_motor(motor, False)
                print(f"[TEST] Pin {pin} OFF, pausing {config.seconds_pause}s")
                time.sleep(config.seconds_pause)
            last_action = "Test sequence completed"
            print("[TEST] Test sequence completed")
        except Exception as exc:
            motor_error = f"{type(exc).__name__}: {exc}"
            last_action = f"Relay test failed: {motor_error}"
            print(f"[ERROR] Relay test failed: {motor_error}")
        finally:
            _all_off()
            with motor_lock:
                active_motor = None

    test_thread = threading.Thread(target=test_sequence, daemon=True)
    test_thread.start()

    return {
        "status": "success",
        "message": "Test sequence started",
        "config": {
            "seconds_on": config.seconds_on,
            "seconds_pause": config.seconds_pause,
            "pins": pins
        }
    }

if __name__ == "__main__":
    import uvicorn
    print("=" * 60)
    print("Motor Control API Server")
    print("=" * 60)
    print("Starting server on http://127.0.0.1:8001")
    print("API Documentation: http://localhost:8001/docs")
    print("ReDoc: http://localhost:8001/redoc")
    print("=" * 60)
    uvicorn.run(
        app,
        host=os.getenv("ROBOPARK_MOTOR_HOST", "127.0.0.1"),
        port=int(os.getenv("ROBOPARK_MOTOR_PORT", "8001")),
    )
