import json
import os
import threading
from time import monotonic, sleep

from AppClient import AppClient
from DataTypes.MotionState import MotionState, InterpolatorState
from DataTypes.RobotState import RobotState
from DataTypes.SystemInfo import SystemInfo
import error_codes
import info_formatting
import robotcontrolapp_pb2


_HERE = os.path.dirname(__file__)
_CONFIG_PATH = os.path.join(_HERE, "config.json")
_DEFAULT_ERROR_CODES_PATH = os.path.join(_HERE, "error_codes.txt")

# License feature that must be granted by the robot control for this app
LICENSE_FEATURE_ID = "APP_OperatorHMI"
LICENSE_POLL_INTERVAL = 60.0  # seconds between license checks
LICENSE_RETRY_INTERVAL = 10.0  # retry sooner after a failed check


def evaluate_license(licensed: bool, trial_deadline: float, now: float) -> dict:
    """Builds the license state dict published to the frontend.

    The app is usable if the feature is licensed or the trial period
    (tracked as a local monotonic deadline) has not expired yet. Only `valid`
    drives the lock overlay; `licensed`/`trialRemainingSeconds` are kept for
    diagnostics via /api/state.
    """
    remaining = max(0, int(trial_deadline - now))
    return {
        "valid": bool(licensed) or remaining > 0,
        "licensed": bool(licensed),
        "trialRemainingSeconds": remaining,
    }


def _resolveErrorCodesPath():
    """Returns the file path for the error-code table.

    Reads optional `errorCodesFile` from config.json (relative paths resolve
    against the operator_hmi_python/ directory). Falls back to error_codes.txt
    next to this module.
    """
    try:
        with open(_CONFIG_PATH, "r") as f:
            cfg = json.load(f)
        override = cfg.get("errorCodesFile")
        if override:
            if not os.path.isabs(override):
                override = os.path.join(_HERE, override)
            return override
    except (OSError, ValueError):
        pass
    return _DEFAULT_ERROR_CODES_PATH


class OperatorHMIApp(AppClient):
    """Operator HMI app that bridges gRPC robot state to a web frontend."""

    def __init__(self, appName: str, target: str):
        AppClient.__init__(self, appName, target)
        self._state_lock = threading.Lock()
        self._latest_state = {}
        self._polling_thread = None
        self._polling_active = False
        self._errorCodes = error_codes.load_error_codes(_resolveErrorCodesPath())
        self._license_checked = False  # fail-open until the first successful check
        self._license_licensed = False
        self._license_trial_deadline = 0.0  # monotonic timestamp
        self._license_next_check = None  # None = check immediately

    def _AppFunctionHandler(self, function):
        # Not used - web app has no server-side app functions
        return

    def _UiUpdateHandler(self, updates):
        # Not used - web app manages UI entirely in the browser
        return

    def StartPolling(self):
        """Starts the background polling thread."""
        self._license_next_check = None  # revalidate immediately after (re)connect
        self._polling_active = True
        self._polling_thread = threading.Thread(
            target=self._pollingLoop, daemon=True
        )
        self._polling_thread.start()

    def StopPolling(self):
        """Stops the background polling thread."""
        self._polling_active = False
        if self._polling_thread:
            self._polling_thread.join(timeout=2)

    def GetLatestState(self) -> dict:
        """Returns the latest serialized state dict (thread-safe)."""
        with self._state_lock:
            return self._latest_state.copy()

    def _updateLicense(self):
        """Checks the license periodically and publishes the current state.

        The robot control is asked every LICENSE_POLL_INTERVAL seconds; between
        polls the trial period counts down against a local monotonic deadline
        so it expires on time despite the coarse poll interval. On errors the
        last known state is kept and the check is retried sooner.
        """
        now = monotonic()
        if self._license_next_check is None or now >= self._license_next_check:
            try:
                info = self.GetLicenseInfo()
                details = info.features.get(LICENSE_FEATURE_ID)
                self._license_licensed = bool(details.isLicensed) if details else False
                self._license_trial_deadline = now + max(0, info.testDurationRemaining)
                self._license_checked = True
                self._license_next_check = now + LICENSE_POLL_INTERVAL
            except Exception as e:
                # Keep the last known license state, but make the failure
                # visible: a persistently failing check means the app never
                # gets a definitive license answer (fail-open), which would
                # otherwise go unnoticed.
                print(f"License check failed, keeping last known state: {e}")
                self._license_next_check = now + LICENSE_RETRY_INTERVAL
        if self._license_checked:
            with self._state_lock:
                self._latest_state["license"] = evaluate_license(
                    self._license_licensed,
                    self._license_trial_deadline,
                    now,
                )

    # Program variables to poll (variable name → raw-interface key)
    _VARIABLE_NAMES = {
        "#parts-good": "parts_good",
        "#parts-bad":  "parts_bad",
    }

    def _pollStatistics(self):
        """Reads statistics from the robot control, returns None if not supported."""
        try:
            stats = self.GetStatistics(False)
            return {
                "uptime_complete": stats.uptimeComplete,
                "uptime_last": stats.uptimeLast,
                "uptime_enabled": stats.uptimeEnabled,
                "uptime_motion": stats.uptimeMotion,
                "program_starts_total": stats.programStartsTotal,
                "program_starts_last": stats.programStartsLast,
                "program_duration_last": stats.programDurationLast,
                "parts_good": stats.partsGood,
                "parts_bad": stats.partsBad,
                "robot_axis_direction_changes": list(stats.robotAxisDirectionChanges),
                "external_axis_direction_changes": list(stats.externalAxisDirectionChanges),
            }
        except Exception:
            return None

    def _pollVariables(self):
        """Reads raw counters, returns display-ready strings for the frontend."""
        raw = {}

        # Try GetStatistics first (preferred, once available in robot control)
        stats = self._pollStatistics()
        if stats is not None:
            raw.update(stats)
        else:
            # Fallback: read parts counters from program variables
            for var_name, key in self._VARIABLE_NAMES.items():
                try:
                    var = self.GetNumberVariable(var_name)
                    raw[key] = var.value
                except Exception:
                    pass

        return info_formatting.build_info_variables(raw)

    def _pollingLoop(self):
        """Polls robot state every 200ms and system info every 5s."""
        cycle = 0
        while self._polling_active and self.IsConnected():
            self._updateLicense()

            try:
                state_dict = {}

                robot_state = self.GetRobotState()
                state_dict["robot"] = _serializeRobotState(robot_state, self._errorCodes)

                motion_state = self.GetMotionState()
                state_dict["motion"] = _serializeMotionState(motion_state)

                # System info + variables every ~5s (25 cycles * 200ms)
                if cycle % 25 == 0:
                    system_info = self.GetSystemInfo()
                    state_dict["system"] = _serializeSystemInfo(system_info)
                    state_dict["variables"] = self._pollVariables()

                with self._state_lock:
                    # Merge so system info persists between polls
                    self._latest_state.update(state_dict)

            except Exception as e:
                with self._state_lock:
                    self._latest_state["error"] = str(e)

            cycle += 1
            sleep(0.2)


_REFERENCING_STATES = {
    robotcontrolapp_pb2.ReferencingState.NOT_REFERENCED: "not referenced",
    robotcontrolapp_pb2.ReferencingState.IS_REFERENCED: "referenced",
    robotcontrolapp_pb2.ReferencingState.IS_REFERENCING: "referencing...",
}

_KINEMATIC_STATES = {
    robotcontrolapp_pb2.KinematicState.KINEMATIC_NORMAL: "normal",
}

_RUN_STATES = {
    robotcontrolapp_pb2.RunState.NOT_RUNNING: "not running",
    robotcontrolapp_pb2.RunState.RUNNING: "running",
    robotcontrolapp_pb2.RunState.PAUSED: "paused",
}

_REPLAY_MODES = {
    robotcontrolapp_pb2.ReplayMode.SINGLE: "single",
    robotcontrolapp_pb2.ReplayMode.REPEAT: "repeat",
    robotcontrolapp_pb2.ReplayMode.STEP: "step",
}


def _serializeKinematicState(value):
    """Maps a kinematic state to a display string.

    Known values use the mapping above; other valid enum values fall back to
    their enum name (unchanged behavior). An unexpected value that is not a
    valid enum member is returned as its raw number instead of raising — a
    single odd value must not blank the whole state.
    """
    mapped = _KINEMATIC_STATES.get(value)
    if mapped is not None:
        return mapped
    try:
        return robotcontrolapp_pb2.KinematicState.Name(value)
    except ValueError:
        return str(value)


def _serializeRobotState(state: RobotState, errorTable: dict) -> dict:
    joints = [
        {
            "id": j.id,
            "name": j.name,
            "actualPosition": round(j.actualPosition, 3),
            "targetPosition": round(j.targetPosition, 3),
            "referencingState": _REFERENCING_STATES.get(j.referencingState, "n/a"),
            "temperatureBoard": j.temperatureBoard,
            "temperatureMotor": j.temperatureMotor,
            "current": j.current,
        }
        for j in state.joints
    ]

    resolved = error_codes.resolve(errorTable, state.hardwareState)

    return {
        "tcp": {
            "x": round(state.tcp.GetX(), 2),
            "y": round(state.tcp.GetY(), 2),
            "z": round(state.tcp.GetZ(), 2),
            "a": round(state.tcp.GetA(), 2),
            "b": round(state.tcp.GetB(), 2),
            "c": round(state.tcp.GetC(), 2),
        },
        "joints": joints,
        "hardwareState": state.hardwareState,
        "errorTitle": resolved["title"],
        "errorDescription": resolved["description"],
        "kinematicState": _serializeKinematicState(state.kinematicState),
        "referencingState": _REFERENCING_STATES.get(state.referencingState, "n/a"),
        "velocityOverride": round(state.velocityOverride, 1),
        "cartesianVelocity": round(state.cartesianVelocity, 1),
        "temperatureCPU": state.temperatureCPU,
        "supplyVoltage": state.supplyVoltage,
        "currentAll": state.currentAll,
        "isEnabled": state.IsEnabled(),
    }


def _serializeInterpolatorState(ipo: InterpolatorState) -> dict:
    return {
        "runState": _RUN_STATES.get(ipo.runState, "n/a"),
        "replayMode": _REPLAY_MODES.get(ipo.replayMode, "n/a"),
        "mainProgram": ipo.mainProgram,
        "currentProgram": ipo.currentProgram,
        "currentProgramIndex": ipo.currentProgramIndex,
        "programCount": ipo.programCount,
        "currentCommandIndex": ipo.currentCommandIndex,
        "commandCount": ipo.commandCount,
    }


def _serializeMotionState(state: MotionState) -> dict:
    return {
        "motionProgram": _serializeInterpolatorState(state.motionProgram),
        "logicProgram": _serializeInterpolatorState(state.logicProgram),
    }


def _serializeSystemInfo(info: SystemInfo) -> dict:
    system_type_map = {
        robotcontrolapp_pb2.SystemInfo.Other: "Other",
        robotcontrolapp_pb2.SystemInfo.Windows: "Windows",
        robotcontrolapp_pb2.SystemInfo.Linux_x86: "Linux x86",
        robotcontrolapp_pb2.SystemInfo.Raspberry: "Raspberry Pi",
    }
    return {
        "version": info.version,
        "projectFile": info.projectFile,
        "projectTitle": info.projectTitle,
        "robotType": info.robotType,
        "systemType": system_type_map.get(info.systemType, "Unknown"),
        "isSimulation": info.isSimulation,
        "robotAxisCount": info.robotAxisCount,
        "externalAxisCount": info.externalAxisCount,
    }
