import argparse
import ctypes
import os
from pathlib import Path
import struct
import sys


SUCCESS = 0
PROTOCOL_AUTO = 0
TRIGGER_OFF = 0x00
TRIGGER_ON_LEVEL = 0xBB

CAP_INTEGRATION_TIME = 0x00000001
CAP_AVERAGE_NUMBER = 0x00000002
CAP_WAVELENGTH_DATA = 0x00000010
CAP_SPECTRUM_DATA = 0x00000020
CAP_TEMPERATURE = 0x00000040
CAP_EXTERNAL_TRIGGER = 0x00000800


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),
    ]


class PynectDirectDeviceInfo(ctypes.Structure):
    _fields_ = [
        ("protocol", ctypes.c_int),
        ("capabilities", ctypes.c_uint32),
        ("protocol_name", ctypes.c_char * 32),
        ("model", ctypes.c_char * 32),
        ("serial_number", ctypes.c_char * 32),
        ("hardware_version", ctypes.c_char * 64),
    ]


P_U8 = ctypes.POINTER(ctypes.c_uint8)
P_U16 = ctypes.POINTER(ctypes.c_uint16)
P_U32 = ctypes.POINTER(ctypes.c_uint32)
P_U64 = ctypes.POINTER(ctypes.c_uint64)
P_UINT = ctypes.POINTER(ctypes.c_uint)
P_INT = ctypes.POINTER(ctypes.c_int)
P_FLOAT = ctypes.POINTER(ctypes.c_float)
P_DOUBLE = ctypes.POINTER(ctypes.c_double)
P_CHAR = ctypes.POINTER(ctypes.c_char)


API_SIGNATURES = {
    "pynect_direct_scan_devices": (ctypes.c_int, []),
    "pynect_direct_get_chip_serial_by_index": (
        ctypes.c_int,
        [ctypes.c_int, P_CHAR, ctypes.c_int],
    ),
    "pynect_direct_open_device": (ctypes.c_int, [ctypes.c_int]),
    "pynect_direct_open_device_by_chip_serial": (
        ctypes.c_int,
        [ctypes.c_char_p],
    ),
    "pynect_direct_close_device": (None, []),
    "pynect_direct_is_open": (ctypes.c_int, []),
    "pynect_direct_get_open_device_count": (ctypes.c_int, []),
    "pynect_direct_select_device": (ctypes.c_int, [ctypes.c_int]),
    "pynect_direct_close_all_devices": (None, []),
    "pynect_direct_set_protocol_preference": (ctypes.c_int, [ctypes.c_int]),
    "pynect_direct_get_device_protocol": (ctypes.c_int, [P_INT]),
    "pynect_direct_get_capabilities": (ctypes.c_int, [P_U32]),
    "pynect_direct_get_device_info": (
        ctypes.c_int,
        [ctypes.POINTER(PynectDirectDeviceInfo)],
    ),
    "pynect_direct_set_integration_time": (ctypes.c_int, [ctypes.c_uint]),
    "pynect_direct_get_integration_time": (ctypes.c_int, [P_UINT]),
    "pynect_direct_set_xenon_pulse": (
        ctypes.c_int,
        [ctypes.c_uint, ctypes.c_uint],
    ),
    "pynect_direct_get_xenon_pulse": (ctypes.c_int, [P_UINT, P_UINT]),
    "pynect_direct_set_xenon_mode": (ctypes.c_int, [ctypes.c_uint8]),
    "pynect_direct_get_xenon_mode": (ctypes.c_int, [P_U8]),
    "pynect_direct_set_average_number": (ctypes.c_int, [ctypes.c_uint8]),
    "pynect_direct_get_average_number": (ctypes.c_int, [P_U8]),
    "pynect_direct_set_level_output": (ctypes.c_int, [ctypes.c_uint8]),
    "pynect_direct_get_level_output": (ctypes.c_int, [P_U8]),
    "pynect_direct_set_flash_write_protect": (ctypes.c_int, []),
    "pynect_direct_get_flash_write_protect": (ctypes.c_int, [P_INT]),
    "pynect_direct_reset_device": (ctypes.c_int, []),
    "pynect_direct_get_calibration_coefficients": (ctypes.c_int, [P_DOUBLE]),
    "pynect_direct_get_temperature": (ctypes.c_int, [P_FLOAT]),
    "pynect_direct_get_hardware_version": (
        ctypes.c_int,
        [P_CHAR, ctypes.c_int],
    ),
    "pynect_direct_get_serial_number": (ctypes.c_int, [P_CHAR, ctypes.c_int]),
    "pynect_direct_get_detector_serial_number": (
        ctypes.c_int,
        [P_CHAR, ctypes.c_int],
    ),
    "pynect_direct_get_device_model": (ctypes.c_int, [P_CHAR, ctypes.c_int]),
    "pynect_direct_set_usb_baud_rate": (ctypes.c_int, [ctypes.c_uint16]),
    "pynect_direct_get_usb_baud_rate": (ctypes.c_int, [P_U16]),
    "pynect_direct_get_usb_status": (ctypes.c_int, [P_INT]),
    "pynect_direct_get_wavelength_count": (ctypes.c_int, [P_U16]),
    "pynect_direct_get_full_wavelength_range": (
        ctypes.c_int,
        [P_DOUBLE, P_DOUBLE],
    ),
    "pynect_direct_get_full_wavelength_data": (
        ctypes.c_int,
        [P_DOUBLE, ctypes.c_int],
    ),
    "pynect_direct_get_full_spectrum_data": (
        ctypes.c_int,
        [P_U16, ctypes.c_int],
    ),
    "pynect_direct_set_selected_wavelength_range": (
        ctypes.c_int,
        [ctypes.c_double, ctypes.c_double],
    ),
    "pynect_direct_get_selected_wavelength_range": (
        ctypes.c_int,
        [ctypes.POINTER(PynectDirectSelectedWavelengthRange)],
    ),
    "pynect_direct_get_selected_wavelength_data": (
        ctypes.c_int,
        [P_DOUBLE, ctypes.c_int],
    ),
    "pynect_direct_get_selected_spectrum_data": (
        ctypes.c_int,
        [P_U16, ctypes.c_int],
    ),
    "pynect_direct_flash_read": (
        ctypes.c_int,
        [ctypes.c_uint16, P_U8, ctypes.c_int],
    ),
    "pynect_direct_flash_write": (
        ctypes.c_int,
        [ctypes.c_uint16, P_U8, ctypes.c_int],
    ),
    "pynect_direct_set_external_trigger_config": (
        ctypes.c_int,
        [ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8],
    ),
    "pynect_direct_get_external_trigger_config": (
        ctypes.c_int,
        [P_U8, P_U8, P_U8],
    ),
    "pynect_direct_get_spectrum_capture_status": (ctypes.c_int, [P_U64]),
    "pynect_direct_external_spectrum_control": (
        ctypes.c_int,
        [ctypes.c_uint16, P_U8, ctypes.c_int],
    ),
    "pynect_direct_set_dac_voltage": (ctypes.c_int, [ctypes.c_uint16]),
    "pynect_direct_get_dac_voltage": (ctypes.c_int, [P_U16]),
    "pynect_direct_set_stored_integration_time": (
        ctypes.c_int,
        [ctypes.c_uint],
    ),
    "pynect_direct_set_stored_average_number": (
        ctypes.c_int,
        [ctypes.c_uint8],
    ),
    "pynect_direct_set_stored_smoothing_width": (
        ctypes.c_int,
        [ctypes.c_uint8],
    ),
    "pynect_direct_get_stored_integration_time": (ctypes.c_int, [P_UINT]),
    "pynect_direct_get_stored_average_number": (ctypes.c_int, [P_U8]),
    "pynect_direct_get_stored_smoothing_width": (ctypes.c_int, [P_U8]),
    "pynect_direct_get_slit_width": (ctypes.c_int, [P_U8]),
}


class Reporter:
    def __init__(self):
        self.passed = 0
        self.skipped = 0
        self.failed = 0

    def pass_item(self, name, detail=""):
        self.passed += 1
        self._print("PASS", name, detail)

    def skip_item(self, name, detail=""):
        self.skipped += 1
        self._print("SKIP", name, detail)

    def fail_item(self, name, detail=""):
        self.failed += 1
        self._print("FAIL", name, detail)

    @staticmethod
    def _print(status, name, detail):
        suffix = f": {detail}" if detail else ""
        print(f"[{status}] {name}{suffix}")

    def finish(self):
        print(
            f"\nSummary: PASS={self.passed} SKIP={self.skipped} FAIL={self.failed}"
        )
        return 1 if self.failed else 0


def parse_args(argv=None):
    parser = argparse.ArgumentParser(
        description="PynectDirect minimal hardware test"
    )
    parser.add_argument("--dll", type=Path, help="explicit PynectDirect.dll path")
    parser.add_argument("--device-index", type=int, default=0)
    parser.add_argument("--read-only", action="store_true")
    parser.add_argument("--integration-us", type=int, default=50_000)
    parser.add_argument("--average", type=int, default=1)
    return parser.parse_args(argv)


def find_dll(explicit_path=None):
    if explicit_path is not None:
        candidate = explicit_path.expanduser().resolve()
        if candidate.is_file():
            return candidate
        raise FileNotFoundError(f"DLL not found: {candidate}")

    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
    searched = ", ".join(str(candidate) for candidate in candidates)
    raise FileNotFoundError(f"PynectDirect.dll not found. Searched: {searched}")


def load_library(dll_path):
    if struct.calcsize("P") != 8:
        raise RuntimeError("PynectDirect.dll requires 64-bit Python")
    dll_path = Path(dll_path).resolve()
    search_directories = [dll_path.parent]
    package_bin = dll_path.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._dll_directory_handle = handles[0] if handles else None
    return library


def bind_api(library):
    missing = []
    for name, (restype, argtypes) in API_SIGNATURES.items():
        try:
            function = getattr(library, name)
        except AttributeError:
            missing.append(name)
            continue
        function.restype = restype
        function.argtypes = argtypes
    if missing:
        raise RuntimeError("Missing DLL exports: " + ", ".join(sorted(missing)))


def decode_char_array(value):
    return bytes(value).split(b"\0", 1)[0].decode("utf-8", errors="replace")


def get_chip_serial(sdk, index):
    buffer = ctypes.create_string_buffer(64)
    result = sdk.pynect_direct_get_chip_serial_by_index(index, buffer, len(buffer))
    return result, buffer.value.decode("utf-8", errors="replace")


def report_result(reporter, name, code, detail=""):
    if code == SUCCESS:
        reporter.pass_item(name, detail)
        return True
    reporter.fail_item(name, f"return code {code}" + (f", {detail}" if detail else ""))
    return False


def test_identity(sdk, reporter, capabilities):
    info = PynectDirectDeviceInfo()
    code = sdk.pynect_direct_get_device_info(ctypes.byref(info))
    if report_result(reporter, "Device information", code):
        reporter.pass_item(
            "Device identity",
            "protocol={} model={} serial={} hardware={}".format(
                decode_char_array(info.protocol_name),
                decode_char_array(info.model),
                decode_char_array(info.serial_number),
                decode_char_array(info.hardware_version),
            ),
        )

    protocol = ctypes.c_int()
    code = sdk.pynect_direct_get_device_protocol(ctypes.byref(protocol))
    report_result(reporter, "Device protocol", code, f"value={protocol.value}")

    if capabilities & CAP_TEMPERATURE:
        temperature = ctypes.c_float()
        code = sdk.pynect_direct_get_temperature(ctypes.byref(temperature))
        report_result(reporter, "Temperature", code, f"{temperature.value:.2f} C")
    else:
        reporter.skip_item("Temperature", "capability not supported")


def prepare_temporary_settings(sdk, reporter, capabilities, args):
    saved = {}
    if args.read_only:
        reporter.skip_item("Temporary settings", "--read-only enabled")
        return saved

    if capabilities & CAP_INTEGRATION_TIME:
        value = ctypes.c_uint()
        code = sdk.pynect_direct_get_integration_time(ctypes.byref(value))
        if code == SUCCESS:
            saved["integration"] = value.value
            code = sdk.pynect_direct_set_integration_time(args.integration_us)
            report_result(
                reporter,
                "Set temporary integration time",
                code,
                f"{args.integration_us} us",
            )
        else:
            reporter.fail_item("Read integration time", f"return code {code}")

    if capabilities & CAP_AVERAGE_NUMBER:
        value = ctypes.c_uint8()
        code = sdk.pynect_direct_get_average_number(ctypes.byref(value))
        if code == SUCCESS:
            saved["average"] = value.value
            code = sdk.pynect_direct_set_average_number(args.average)
            report_result(reporter, "Set temporary average", code, str(args.average))
        else:
            reporter.fail_item("Read average count", f"return code {code}")

    if capabilities & CAP_EXTERNAL_TRIGGER:
        enable = ctypes.c_uint8()
        trigger_type = ctypes.c_uint8()
        scan_count = ctypes.c_uint8()
        code = sdk.pynect_direct_get_external_trigger_config(
            ctypes.byref(enable), ctypes.byref(trigger_type), ctypes.byref(scan_count)
        )
        if code == SUCCESS:
            original = (enable.value, trigger_type.value, scan_count.value)
            test_value = (TRIGGER_OFF, TRIGGER_ON_LEVEL, 1)
            if original == test_value:
                reporter.skip_item(
                    "Set temporary software trigger",
                    f"original already equals test value 0x{original[0]:02X},"
                    f"0x{original[1]:02X},{original[2]}",
                )
            else:
                saved["trigger"] = original
                code = sdk.pynect_direct_set_external_trigger_config(*test_value)
                report_result(
                    reporter,
                    "Set temporary software trigger",
                    code,
                    f"original=(0x{original[0]:02X},0x{original[1]:02X},"
                    f"{original[2]})",
                )
        else:
            reporter.fail_item("Read trigger configuration", f"return code {code}")
    return saved


def restore_temporary_settings(sdk, reporter, saved):
    if "trigger" in saved:
        detail = (
            f"restore=(0x{saved['trigger'][0]:02X},0x{saved['trigger'][1]:02X},"
            f"{saved['trigger'][2]})"
        )
        code = sdk.pynect_direct_set_external_trigger_config(*saved["trigger"])
        if code != SUCCESS:
            code = sdk.pynect_direct_set_external_trigger_config(*saved["trigger"])
            detail += ", retry ok" if code == SUCCESS else ", retry failed"
        report_result(reporter, "Restore trigger configuration", code, detail)
    if "average" in saved:
        code = sdk.pynect_direct_set_average_number(saved["average"])
        report_result(reporter, "Restore average count", code)
    if "integration" in saved:
        code = sdk.pynect_direct_set_integration_time(saved["integration"])
        report_result(reporter, "Restore integration time", code)


def test_spectrum(sdk, reporter, capabilities, args):
    count = ctypes.c_uint16()
    code = sdk.pynect_direct_get_wavelength_count(ctypes.byref(count))
    if not report_result(reporter, "Wavelength count", code, str(count.value)):
        return
    if count.value == 0:
        reporter.fail_item("Wavelength count", "device returned zero points")
        return

    start_nm = ctypes.c_double()
    end_nm = ctypes.c_double()
    code = sdk.pynect_direct_get_full_wavelength_range(
        ctypes.byref(start_nm), ctypes.byref(end_nm)
    )
    if not report_result(
        reporter,
        "Full wavelength range",
        code,
        f"{start_nm.value:.3f}-{end_nm.value:.3f} nm",
    ):
        return
    if end_nm.value <= start_nm.value:
        reporter.fail_item("Full wavelength range", "end must be greater than start")
        return

    if capabilities & CAP_WAVELENGTH_DATA:
        wavelengths = (ctypes.c_double * count.value)()
        code = sdk.pynect_direct_get_full_wavelength_data(wavelengths, count.value)
        report_result(
            reporter,
            "Full wavelength data",
            code,
            f"points={count.value} first={wavelengths[0]:.3f} last={wavelengths[-1]:.3f}",
        )
    else:
        reporter.skip_item("Full wavelength data", "capability not supported")

    if capabilities & CAP_SPECTRUM_DATA:
        spectrum = (ctypes.c_uint16 * count.value)()
        code = sdk.pynect_direct_get_full_spectrum_data(spectrum, count.value)
        report_result(
            reporter,
            "Full spectrum",
            code,
            f"points={count.value} min={min(spectrum)} max={max(spectrum)}",
        )
    else:
        reporter.skip_item("Full spectrum", "capability not supported")

    if args.read_only:
        reporter.skip_item("Selected wavelength range", "--read-only enabled")
        return
    if start_nm.value > 400.0 or end_nm.value < 800.0:
        reporter.skip_item(
            "Selected wavelength range", "device does not cover 400-800 nm"
        )
        return

    code = sdk.pynect_direct_set_selected_wavelength_range(400.0, 800.0)
    if not report_result(reporter, "Set selected range", code, "400-800 nm"):
        return
    selected = PynectDirectSelectedWavelengthRange()
    code = sdk.pynect_direct_get_selected_wavelength_range(ctypes.byref(selected))
    if not report_result(
        reporter,
        "Selected range metadata",
        code,
        f"pixels={selected.start_pixel}-{selected.end_pixel} count={selected.count}",
    ):
        return
    if selected.count == 0:
        reporter.fail_item("Selected range metadata", "device returned zero points")
        return

    selected_wavelengths = (ctypes.c_double * selected.count)()
    code = sdk.pynect_direct_get_selected_wavelength_data(
        selected_wavelengths, selected.count
    )
    report_result(reporter, "Selected wavelength data", code)

    selected_spectrum = (ctypes.c_uint16 * selected.count)()
    code = sdk.pynect_direct_get_selected_spectrum_data(
        selected_spectrum, selected.count
    )
    report_result(reporter, "Selected spectrum", code)


def run(args):
    reporter = Reporter()
    opened = False
    saved = {}
    sdk = None
    try:
        dll_path = find_dll(args.dll)
        sdk = load_library(dll_path)
        reporter.pass_item("DLL load", str(dll_path))
        bind_api(sdk)
        reporter.pass_item("API exports", f"{len(API_SIGNATURES)} functions")

        if args.device_index < 0:
            reporter.fail_item("Device index", "must be non-negative")
            return reporter.finish()
        if not 1 <= args.average <= 255:
            reporter.fail_item("Average count", "must be in range 1-255")
            return reporter.finish()
        if args.integration_us <= 0:
            reporter.fail_item("Integration time", "must be positive")
            return reporter.finish()

        count = sdk.pynect_direct_scan_devices()
        if count <= 0:
            reporter.fail_item("Device scan", f"found {count} devices")
            return reporter.finish()
        reporter.pass_item("Device scan", f"found {count} device(s)")
        if args.device_index >= count:
            reporter.fail_item(
                "Device index", f"index {args.device_index}, available 0-{count - 1}"
            )
            return reporter.finish()

        code, chip_serial = get_chip_serial(sdk, args.device_index)
        report_result(reporter, "FTDI chip serial", code, chip_serial)

        code = sdk.pynect_direct_set_protocol_preference(PROTOCOL_AUTO)
        report_result(reporter, "Protocol preference", code, "automatic")

        code = sdk.pynect_direct_open_device(args.device_index)
        if not report_result(reporter, "Open device", code, str(args.device_index)):
            return reporter.finish()
        opened = True
        if sdk.pynect_direct_is_open():
            reporter.pass_item("Open state", "open")
        else:
            reporter.fail_item("Open state", "SDK reports closed")

        capabilities = ctypes.c_uint32()
        code = sdk.pynect_direct_get_capabilities(ctypes.byref(capabilities))
        if not report_result(
            reporter, "Capabilities", code, f"0x{capabilities.value:08X}"
        ):
            return reporter.finish()

        test_identity(sdk, reporter, capabilities.value)
        saved = prepare_temporary_settings(sdk, reporter, capabilities.value, args)
        test_spectrum(sdk, reporter, capabilities.value, args)
    except (OSError, RuntimeError, FileNotFoundError) as error:
        reporter.fail_item("Test setup", str(error))
    except Exception as error:
        reporter.fail_item("Unexpected error", f"{type(error).__name__}: {error}")
    finally:
        if sdk is not None and opened:
            restore_temporary_settings(sdk, reporter, saved)
            sdk.pynect_direct_close_device()
            if sdk.pynect_direct_is_open():
                reporter.fail_item("Close device", "SDK still reports open")
            else:
                reporter.pass_item("Close device", "closed")
    return reporter.finish()


def main(argv=None):
    try:
        args = parse_args(argv)
    except SystemExit as error:
        return int(error.code)
    return run(args)


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