import ctypes
from dataclasses import dataclass
import math
import os
from pathlib import Path
import struct
import sys


SUCCESS = 0


class PynectDirectSelectedWavelengthRange(ctypes.Structure):
    _fields_ = [
        ("request_start_nm", ctypes.c_double),
        ("request_end_nm", ctypes.c_double),
        ("actual_start_nm", ctypes.c_double),
        ("actual_end_nm", ctypes.c_double),
        ("start_pixel", ctypes.c_uint16),
        ("end_pixel", ctypes.c_uint16),
        ("count", ctypes.c_uint16),
    ]


@dataclass(frozen=True)
class SelectedSpectrumResult:
    serial_number: str
    full_start_nm: float
    full_end_nm: float
    integration_us: int
    average_count: int
    request_start_nm: float
    request_end_nm: float
    actual_start_nm: float
    actual_end_nm: float
    start_pixel: int
    end_pixel: int
    wavelengths: list
    intensities: list


def find_dll():
    script_dir = Path(__file__).resolve().parent
    package_root = script_dir.parent
    candidates = [
        script_dir / "PynectDirect.dll",
        script_dir / "dll" / "PynectDirect.dll",
        package_root / "bin" / "PynectDirect.dll",
        package_root / "build-pynect-direct-gcc" / "PynectDirect.dll",
    ]
    for candidate in candidates:
        if candidate.is_file():
            return candidate.resolve()
    searched = ", ".join(str(candidate) for candidate in candidates)
    raise FileNotFoundError(f"PynectDirect.dll not found. Searched: {searched}")


def load_library():
    if struct.calcsize("P") != 8:
        raise RuntimeError("PynectDirect.dll requires 64-bit Python")

    dll_path = find_dll()
    search_directories = [dll_path.parent]
    package_bin = Path(__file__).resolve().parent.parent / "bin"
    if package_bin.is_dir() and package_bin not in search_directories:
        search_directories.append(package_bin)

    handles = []
    if hasattr(os, "add_dll_directory"):
        handles = [
            os.add_dll_directory(str(directory))
            for directory in search_directories
        ]

    library = ctypes.CDLL(str(dll_path))
    library._dll_directory_handles = handles
    library._pynect_direct_path = dll_path
    return library


def bind_api(sdk):
    sdk.pynect_direct_scan_devices.argtypes = []
    sdk.pynect_direct_scan_devices.restype = ctypes.c_int

    sdk.pynect_direct_open_device.argtypes = [ctypes.c_int]
    sdk.pynect_direct_open_device.restype = ctypes.c_int

    sdk.pynect_direct_close_device.argtypes = []
    sdk.pynect_direct_close_device.restype = None

    sdk.pynect_direct_get_serial_number.argtypes = [
        ctypes.POINTER(ctypes.c_char),
        ctypes.c_int,
    ]
    sdk.pynect_direct_get_serial_number.restype = ctypes.c_int

    sdk.pynect_direct_get_full_wavelength_range.argtypes = [
        ctypes.POINTER(ctypes.c_double),
        ctypes.POINTER(ctypes.c_double),
    ]
    sdk.pynect_direct_get_full_wavelength_range.restype = ctypes.c_int

    sdk.pynect_direct_set_integration_time.argtypes = [ctypes.c_uint]
    sdk.pynect_direct_set_integration_time.restype = ctypes.c_int

    sdk.pynect_direct_get_integration_time.argtypes = [
        ctypes.POINTER(ctypes.c_uint)
    ]
    sdk.pynect_direct_get_integration_time.restype = ctypes.c_int

    sdk.pynect_direct_set_average_number.argtypes = [ctypes.c_uint8]
    sdk.pynect_direct_set_average_number.restype = ctypes.c_int

    sdk.pynect_direct_get_average_number.argtypes = [
        ctypes.POINTER(ctypes.c_uint8)
    ]
    sdk.pynect_direct_get_average_number.restype = ctypes.c_int

    sdk.pynect_direct_set_selected_wavelength_range.argtypes = [
        ctypes.c_double,
        ctypes.c_double,
    ]
    sdk.pynect_direct_set_selected_wavelength_range.restype = ctypes.c_int

    sdk.pynect_direct_get_selected_wavelength_range.argtypes = [
        ctypes.POINTER(PynectDirectSelectedWavelengthRange)
    ]
    sdk.pynect_direct_get_selected_wavelength_range.restype = ctypes.c_int

    sdk.pynect_direct_get_selected_wavelength_data.argtypes = [
        ctypes.POINTER(ctypes.c_double),
        ctypes.c_int,
    ]
    sdk.pynect_direct_get_selected_wavelength_data.restype = ctypes.c_int

    sdk.pynect_direct_get_selected_spectrum_data.argtypes = [
        ctypes.POINTER(ctypes.c_uint16),
        ctypes.c_int,
    ]
    sdk.pynect_direct_get_selected_spectrum_data.restype = ctypes.c_int

    sdk.pynect_direct_get_wavelength_count.argtypes = [
        ctypes.POINTER(ctypes.c_uint16)
    ]
    sdk.pynect_direct_get_wavelength_count.restype = ctypes.c_int

    sdk.pynect_direct_get_full_wavelength_data.argtypes = [
        ctypes.POINTER(ctypes.c_double),
        ctypes.c_int,
    ]
    sdk.pynect_direct_get_full_wavelength_data.restype = ctypes.c_int

    sdk.pynect_direct_get_full_spectrum_data.argtypes = [
        ctypes.POINTER(ctypes.c_uint16),
        ctypes.c_int,
    ]
    sdk.pynect_direct_get_full_spectrum_data.restype = ctypes.c_int


def require_success(code, operation):
    if code != SUCCESS:
        raise RuntimeError(f"{operation} failed, return code={code}")


def prompt_integer(
    label,
    default,
    minimum,
    maximum,
    input_fn=input,
    output_fn=print,
):
    while True:
        raw = input_fn(f"{label} [{default}]: ").strip()
        if not raw:
            return default
        try:
            value = int(raw)
        except ValueError:
            output_fn(f"Please enter an integer from {minimum} to {maximum}.")
            continue
        if minimum <= value <= maximum:
            return value
        output_fn(f"Please enter an integer from {minimum} to {maximum}.")


def prompt_float(
    label,
    default,
    minimum,
    maximum,
    input_fn=input,
    output_fn=print,
):
    while True:
        raw = input_fn(f"{label} [{default:.3f}]: ").strip()
        if not raw:
            return default
        try:
            value = float(raw)
        except ValueError:
            output_fn(
                f"Please enter a number from {minimum:.3f} "
                f"to {maximum:.3f}."
            )
            continue
        if math.isfinite(value) and minimum <= value <= maximum:
            return value
        output_fn(
            f"Please enter a number from {minimum:.3f} "
            f"to {maximum:.3f}."
        )


def read_full_spectrum(device_index=0):
    if not isinstance(device_index, int) or device_index < 0:
        raise ValueError("device_index must be a non-negative integer")

    sdk = load_library()
    bind_api(sdk)
    opened = False
    try:
        device_count = sdk.pynect_direct_scan_devices()
        if device_count <= 0:
            raise RuntimeError("No PynectDirect device found")
        if device_index >= device_count:
            raise ValueError(
                f"device_index {device_index} is out of range; "
                f"available indexes: 0-{device_count - 1}"
            )

        require_success(
            sdk.pynect_direct_open_device(device_index),
            "Open device",
        )
        opened = True

        point_count = ctypes.c_uint16()
        require_success(
            sdk.pynect_direct_get_wavelength_count(ctypes.byref(point_count)),
            "Get wavelength count",
        )
        if point_count.value == 0:
            raise RuntimeError("Device returned zero wavelength points")

        wavelengths = (ctypes.c_double * point_count.value)()
        intensities = (ctypes.c_uint16 * point_count.value)()
        require_success(
            sdk.pynect_direct_get_full_wavelength_data(
                wavelengths, point_count.value
            ),
            "Get wavelength data",
        )
        require_success(
            sdk.pynect_direct_get_full_spectrum_data(
                intensities, point_count.value
            ),
            "Get spectrum data",
        )
        return list(wavelengths), list(intensities)
    finally:
        if opened:
            sdk.pynect_direct_close_device()


def run_interactive_measurement(input_fn=input, output_fn=print):
    sdk = load_library()
    bind_api(sdk)
    opened = False
    integration_was_set = False
    average_was_set = False
    old_integration = ctypes.c_uint()
    old_average = ctypes.c_uint8()
    try:
        device_count = sdk.pynect_direct_scan_devices()
        if device_count < 0:
            raise RuntimeError(
                f"Scan devices failed, return code={device_count}"
            )
        if device_count == 0:
            raise RuntimeError("No PynectDirect device found")

        device_index = 0
        if device_count > 1:
            output_fn(f"Detected devices: 0-{device_count - 1}")
            device_index = prompt_integer(
                "Device index",
                default=0,
                minimum=0,
                maximum=device_count - 1,
                input_fn=input_fn,
                output_fn=output_fn,
            )

        require_success(
            sdk.pynect_direct_open_device(device_index),
            "Open device",
        )
        opened = True

        serial_buffer = ctypes.create_string_buffer(64)
        require_success(
            sdk.pynect_direct_get_serial_number(
                serial_buffer, len(serial_buffer)
            ),
            "Get device serial number",
        )
        serial_number = serial_buffer.value.decode(
            "utf-8", errors="replace"
        )

        full_start = ctypes.c_double()
        full_end = ctypes.c_double()
        require_success(
            sdk.pynect_direct_get_full_wavelength_range(
                ctypes.byref(full_start), ctypes.byref(full_end)
            ),
            "Get full wavelength range",
        )
        require_success(
            sdk.pynect_direct_get_integration_time(
                ctypes.byref(old_integration)
            ),
            "Get integration time",
        )
        require_success(
            sdk.pynect_direct_get_average_number(
                ctypes.byref(old_average)
            ),
            "Get average count",
        )

        output_fn(f"Device serial number: {serial_number}")
        output_fn(
            f"Full wavelength range: "
            f"{full_start.value:.3f}-{full_end.value:.3f} nm"
        )
        output_fn(f"Current integration time: {old_integration.value} us")
        output_fn(f"Current average count: {old_average.value}")

        integration_us = prompt_integer(
            "Integration time in us",
            default=old_integration.value,
            minimum=1,
            maximum=0xFFFFFFFF,
            input_fn=input_fn,
            output_fn=output_fn,
        )
        average_count = prompt_integer(
            "Average count",
            default=old_average.value,
            minimum=1,
            maximum=255,
            input_fn=input_fn,
            output_fn=output_fn,
        )

        while True:
            request_start = prompt_float(
                "Start wavelength in nm",
                default=full_start.value,
                minimum=full_start.value,
                maximum=full_end.value,
                input_fn=input_fn,
                output_fn=output_fn,
            )
            request_end = prompt_float(
                "End wavelength in nm",
                default=full_end.value,
                minimum=full_start.value,
                maximum=full_end.value,
                input_fn=input_fn,
                output_fn=output_fn,
            )
            if request_start < request_end:
                break
            output_fn("Start wavelength must be less than end wavelength.")

        require_success(
            sdk.pynect_direct_set_integration_time(integration_us),
            "Set integration time",
        )
        integration_was_set = True
        require_success(
            sdk.pynect_direct_set_average_number(average_count),
            "Set average count",
        )
        average_was_set = True
        require_success(
            sdk.pynect_direct_set_selected_wavelength_range(
                request_start, request_end
            ),
            "Set selected wavelength range",
        )

        selected = PynectDirectSelectedWavelengthRange()
        require_success(
            sdk.pynect_direct_get_selected_wavelength_range(
                ctypes.byref(selected)
            ),
            "Get selected wavelength range",
        )
        if selected.count == 0:
            raise RuntimeError("Device returned zero selected points")

        wavelengths = (ctypes.c_double * selected.count)()
        intensities = (ctypes.c_uint16 * selected.count)()
        require_success(
            sdk.pynect_direct_get_selected_wavelength_data(
                wavelengths, selected.count
            ),
            "Get selected wavelength data",
        )
        require_success(
            sdk.pynect_direct_get_selected_spectrum_data(
                intensities, selected.count
            ),
            "Get selected spectrum data",
        )

        wavelength_values = list(wavelengths)
        intensity_values = list(intensities)
        if len(wavelength_values) != len(intensity_values):
            raise RuntimeError("Wavelength and intensity counts differ")

        return SelectedSpectrumResult(
            serial_number=serial_number,
            full_start_nm=full_start.value,
            full_end_nm=full_end.value,
            integration_us=integration_us,
            average_count=average_count,
            request_start_nm=request_start,
            request_end_nm=request_end,
            actual_start_nm=selected.actual_start_nm,
            actual_end_nm=selected.actual_end_nm,
            start_pixel=selected.start_pixel,
            end_pixel=selected.end_pixel,
            wavelengths=wavelength_values,
            intensities=intensity_values,
        )
    finally:
        if opened:
            if average_was_set:
                code = sdk.pynect_direct_set_average_number(
                    old_average.value
                )
                if code != SUCCESS:
                    output_fn(
                        f"WARNING: Restore average count failed, "
                        f"return code={code}"
                    )
            if integration_was_set:
                code = sdk.pynect_direct_set_integration_time(
                    old_integration.value
                )
                if code != SUCCESS:
                    output_fn(
                        f"WARNING: Restore integration time failed, "
                        f"return code={code}"
                    )
            sdk.pynect_direct_close_device()


def select_display_indices(total_count, display_count=10):
    if total_count <= 0:
        return []
    if total_count <= display_count:
        return list(range(total_count))
    return [
        round(index * (total_count - 1) / (display_count - 1))
        for index in range(display_count)
    ]


def print_measurement_result(result, output_fn=print):
    output_fn(f"Device serial number: {result.serial_number}")
    output_fn(
        f"Full wavelength range: "
        f"{result.full_start_nm:.3f}-{result.full_end_nm:.3f} nm"
    )
    output_fn(f"Integration time: {result.integration_us} us")
    output_fn(f"Average count: {result.average_count}")
    output_fn(
        f"Requested wavelength range: "
        f"{result.request_start_nm:.3f}-{result.request_end_nm:.3f} nm"
    )
    output_fn(
        f"Actual wavelength range: "
        f"{result.actual_start_nm:.3f}-{result.actual_end_nm:.3f} nm"
    )
    total_count = len(result.wavelengths)
    display_indices = select_display_indices(total_count)
    output_fn(f"Actual wavelength points read: {total_count}")
    if len(display_indices) == total_count:
        output_fn(f"Showing all {total_count} points:")
    else:
        output_fn(f"Showing {len(display_indices)} evenly spaced points:")
    output_fn("Index\tWavelength (nm)\tIntensity")
    for index in display_indices:
        output_fn(
            f"{index}\t{result.wavelengths[index]:.6f}\t"
            f"{result.intensities[index]}"
        )


def main():
    try:
        result = run_interactive_measurement()
    except (FileNotFoundError, OSError, RuntimeError, ValueError) as error:
        print(f"ERROR: {error}", file=sys.stderr)
        return 1

    print_measurement_result(result)
    return 0


if __name__ == "__main__":
    sys.exit(main())
