#  Copyright 2026 Synnax Labs, Inc.
#
#  Use of this software is governed by the Business Source License included in the file
#  licenses/BSL.txt.
#
#  As of the Change Date specified in that file, in accordance with the Business Source
#  License, use of this software will be governed by the Apache License, Version 2.0,
#  included in the file licenses/APL.txt.

from typing import Literal
from uuid import uuid4
from warnings import warn

from pydantic import BaseModel, Field, field_validator
from typing_extensions import deprecated

from synnax import channel as channel_
from synnax import device, task
from synnax.telem import CrudeDataType, CrudeRate, Rate

# Security mode constants
SecurityMode = Literal["None", "Sign", "SignAndEncrypt"]

# Security policy constants
SecurityPolicy = Literal[
    "None",
    "Basic128Rsa15",
    "Basic256",
    "Basic256Sha256",
    "Aes128_Sha256_RsaOaep",
    "Aes256_Sha256_RsaPss",
]


class ReadChannel(BaseModel):
    """
    Configuration for a channel in an OPC UA read task.

    Each ReadChannel defines a mapping from an OPC UA node on the server to a Synnax
    channel where the data will be written. OPC UA nodes are identified by NodeID,
    which has various formats depending on the namespace and identifier type.

    Common NodeID formats:
    - "NS=2;I=1234" - Numeric identifier in namespace 2
    - "NS=2;S=MyVariable" - String identifier in namespace 2
    - "NS=2;G=550e8400-e29b-41d4-a716-446655440000" - GUID identifier
    - "NS=2;B=YWJjZGVm" - Opaque (Base64) identifier

    For detailed information on OPC UA node addressing, see:
    <https://reference.opcfoundation.org/Core/Part3/v104/docs/8.2>

    Example 1: Reading a temperature sensor value
        >>> # Basic numeric node in namespace 2
        >>> temp_channel = ReadChannel(
        ...     node_id="NS=2;I=1001",
        ...     node_name="Temperature",
        ...     channel=100,
        ...     data_type="float32",
        ...     enabled=True
        ... )

    Example 2: Reading with string-based node ID
        >>> # Node identified by string in namespace 3
        >>> pressure_channel = ReadChannel(
        ...     node_id="NS=3;S=ProcessData.Pressure",
        ...     node_name="PressureSensor",
        ...     channel=101,
        ...     data_type="float64"
        ... )

    Example 3: Using server timestamp as index
        >>> # Let OPC UA server timestamp be the index for this channel
        >>> sensor_with_timestamp = ReadChannel(
        ...     node_id="NS=2;I=2000",
        ...     node_name="FlowRate",
        ...     channel=102,
        ...     data_type="float32",
        ...     use_as_index=True  # Use server timestamp as index
        ... )

    Example 4: Reading integer process value
        >>> # Integer data type from PLC
        >>> conveyor_speed = ReadChannel(
        ...     node_id="NS=4;I=5010",
        ...     node_name="ConveyorSpeed",
        ...     channel=103,
        ...     data_type="int32",
        ...     enabled=True
        ... )

    :param node_id: OPC UA node identifier (e.g., "NS=2;I=1001" or "NS=2;S=Variable")
    :param node_name: Human-readable name for the node (for reference only)
    :param channel: Synnax channel key to write data to
    :param data_type: OPC UA data type (float32, float64, int16, int32, uint16, uint32, boolean, string, etc.)
    :param use_as_index: Use server timestamp as index (default: driver generates timestamps)
    :param enabled: Whether acquisition for this channel is enabled
    :param key: Unique identifier (auto-generated if not provided)
    """

    enabled: bool = True
    "Whether acquisition for this channel is enabled."
    key: str = ""
    "A unique key to identify this channel."
    channel: channel_.Key = 0
    "The Synnax channel key that will be written to during acquisition."
    node_id: str = ""
    "The OPC UA node ID to read from."
    node_name: str = ""
    "The name of the OPC UA node to read from."
    data_type: str = "float32"
    "The OPC UA data type of the channel."
    use_as_index: bool = False
    """
    Whether to use the values of this channel to store index timestamps. If no channels
    are marked as index channels within the task, timestamps will be automatically
    generated by the Synnax OPC UA driver and written to the correct index channels.
    """

    def __init__(
        self,
        *,
        enabled: bool = True,
        key: str = "",
        channel: channel_.Key = 0,
        node_id: str = "",
        node_name: str = "",
        data_type: CrudeDataType = "float32",
        use_as_index: bool = False,
    ):
        if not key:
            key = str(uuid4())
        super().__init__(
            enabled=enabled,
            key=key,
            channel=channel,
            node_id=node_id,
            node_name=node_name,
            data_type=data_type,
            use_as_index=use_as_index,
        )


@deprecated(
    "opcua.Channel is deprecated and will be removed in a future version. Use opcua.ReadChannel instead.",
    category=None,
)
def Channel(
    *,
    enabled: bool = True,
    key: str = "",
    channel: channel_.Key = 0,
    node_id: str = "",
    node_name: str = "",
    data_type: CrudeDataType = "float32",
    use_as_index: bool = False,
) -> ReadChannel:
    warn(
        "opcua.Channel is deprecated and will be removed in a future version. Use opcua.ReadChannel instead.",
        DeprecationWarning,
    )
    return ReadChannel(
        enabled=enabled,
        key=key,
        channel=channel,
        node_id=node_id,
        node_name=node_name,
        data_type=data_type,
        use_as_index=use_as_index,
    )


class WriteChannel(BaseModel):
    """
    Configuration for a channel in an OPC UA write task.

    Each WriteChannel defines a mapping from a Synnax command channel to an OPC UA
    node on the server. When values are written to the Synnax command channel, they
    are automatically sent to the corresponding OPC UA node, enabling control of
    remote equipment and processes.

    Common NodeID formats:
    - "NS=2;I=1234" - Numeric identifier in namespace 2
    - "NS=2;S=MySetpoint" - String identifier in namespace 2
    - "NS=2;G=550e8400-e29b-41d4-a716-446655440000" - GUID identifier
    - "NS=2;B=YWJjZGVm" - Opaque (Base64) identifier

    For detailed information on OPC UA node addressing, see:
    <https://reference.opcfoundation.org/Core/Part3/v104/docs/8.2>

    Example 1: Writing a temperature setpoint
        >>> # Control temperature setpoint on the OPC UA server
        >>> temp_setpoint = WriteChannel(
        ...     node_id="NS=2;I=2001",
        ...     cmd_channel=200,  # Synnax channel with setpoint commands
        ...     enabled=True
        ... )

    Example 2: Writing a valve position
        >>> # Control valve opening percentage (0-100)
        >>> valve_control = WriteChannel(
        ...     node_id="NS=3;S=Actuators.Valve1.Position",
        ...     cmd_channel=201
        ... )

    Example 3: Writing a boolean command
        >>> # Start/stop motor via boolean node
        >>> motor_start = WriteChannel(
        ...     node_id="NS=4;I=3000",
        ...     cmd_channel=202,
        ...     enabled=True
        ... )

    Example 4: Writing a process parameter
        >>> # Update PID controller setpoint
        >>> pid_setpoint = WriteChannel(
        ...     node_id="NS=2;S=Controllers.PID1.Setpoint",
        ...     cmd_channel=203
        ... )

    :param node_id: OPC UA node identifier to write to (e.g., "NS=2;I=1001" or "NS=2;S=Variable")
    :param cmd_channel: Synnax channel key to read command values from
    :param enabled: Whether output for this channel is enabled
    :param key: Unique identifier (auto-generated if not provided)
    """

    enabled: bool = True
    "Whether output for this channel is enabled."
    key: str = ""
    "A unique key to identify this channel."
    cmd_channel: channel_.Key = 0
    "The Synnax channel key to read command values from."
    node_id: str = ""
    "The OPC UA node ID to write to."

    def __init__(
        self,
        *,
        enabled: bool = True,
        key: str = "",
        cmd_channel: channel_.Key = 0,
        node_id: str = "",
    ):
        if not key:
            key = str(uuid4())
        super().__init__(
            enabled=enabled,
            key=key,
            cmd_channel=cmd_channel,
            node_id=node_id,
        )


class BaseConfig(task.BaseConfig):
    """Base configuration for OPC UA read tasks.

    Inherits auto_start from BaseTaskConfig and adds OPC UA-specific fields.
    Does not inherit from BaseReadTaskConfig because OPC UA has conditional stream_rate
    requirements (only present in non-array mode). Includes data_saving for all OPC UA
    read tasks with OPC UA hardware sample rate limits (10 kHz max).
    """

    data_saving: bool = True
    "Whether to persist acquired data to disk (True) or only stream it (False)."
    device: str = Field(min_length=1)
    "The key of the Synnax OPC UA device to read from."
    sample_rate: float = Field(gt=0, le=10000)
    "The rate at which to sample data from the OPC UA device."
    channels: list[ReadChannel]


class NonArraySamplingReadTaskConfig(BaseConfig):
    stream_rate: Rate = Field(gt=0, le=10000)
    array_mode: Literal[False]


class ArraySamplingReadTaskConfig(BaseConfig):
    array_mode: Literal[True]
    array_size: int = Field(gt=0)


class WrappedReadTaskConfig(BaseModel):
    config: NonArraySamplingReadTaskConfig | ArraySamplingReadTaskConfig = Field(
        discriminator="array_mode"
    )


class ReadTask(task.StarterStopperMixin, task.JSONConfigMixin, task.Protocol):
    """A read task for sampling data from OPC UA devices and writing the data to a
    Synnax cluster. This task is a programmatic representation of the OPC UA read
    task configurable within the Synnax console. For detailed information on configuring/
    operating an OPC UA read task, see https://docs.synnaxlabs.com/reference/driver/opc-ua/read-task


    :param device: The key of the Synnax OPC UA device to read from.
    :param name: A human-readable name for the task.
    :param sample_rate: The rate at which to sample data from the OPC UA device.
    :param stream_rate: The rate at which acquired data will be streamed to the Synnax
        cluster. For example, a sample rate of 100Hz and a stream rate of 25Hz will
        result in groups of 4 samples being streamed to the cluster every 40ms.
        This parameter is only relevant when array_mode is False.
    :param data_saving: Whether to save data permanently within Synnax, or just stream
        it for real-time consumption.
    :param auto_start: Whether to start the task automatically when it is created.
    :param array_mode: Whether to sample data in array mode. In array mode, the task
        will read array nodes from the OPC UA device with a consistent size (specified in
        array_size) and write the entire array to the Synnax cluster. This mode is
        far more efficient for collecting data at very high rates, but requires more
        careful setup. For more information,
        see https://docs.synnaxlabs.com/reference/driver/opc-ua/read-task#default-sampling-vs-array-sampling.
    :param: array_size: The size of the array to read from the OPC UA device. This
        field is only relevant if array_mode is set to True.
    :param: channels: A list of Channel objects that specify which OPC UA nodes to read
        from and how to write the data to the Synnax cluster.
    """

    TYPE = "opc_read"
    config: NonArraySamplingReadTaskConfig | ArraySamplingReadTaskConfig
    _internal: task.Task

    def __init__(
        self,
        internal: task.Task | None = None,
        *,
        device: device.Key = "",
        name: str = "",
        sample_rate: CrudeRate = 1000,
        stream_rate: CrudeRate = 1000,
        data_saving: bool = False,
        auto_start: bool = False,
        array_mode: bool = False,
        array_size: int = 1,
        channels: list[ReadChannel] | None = None,
    ):
        if internal is not None:
            self._internal = internal
            self.config = WrappedReadTaskConfig.model_validate(
                {"config": internal.config}
            ).config
            return
        self._internal = task.Task(name=name, type=self.TYPE)
        if array_mode:
            self.config = ArraySamplingReadTaskConfig(
                device=device,
                sample_rate=sample_rate,
                data_saving=data_saving,
                auto_start=auto_start,
                array_mode=array_mode,
                array_size=array_size,
                channels=channels if channels is not None else [],
            )
        else:
            self.config = NonArraySamplingReadTaskConfig(
                device=device,
                sample_rate=sample_rate,
                stream_rate=stream_rate,
                data_saving=data_saving,
                auto_start=auto_start,
                array_mode=array_mode,
                channels=channels if channels is not None else [],
            )

    def update_device_properties(self, device_client: device.Client) -> device.Device:
        """Update device properties before task configuration."""
        dev = device_client.retrieve(key=self.config.device)
        props = dict(dev.properties)

        if "read" not in props:
            props["read"] = {"index": 0, "channels": {}}

        # Map node_id -> channel key for Console
        for ch in self.config.channels:
            if ch.node_id:
                props["read"]["channels"][ch.node_id] = ch.channel

        dev.properties = props
        return device_client.create(dev)


class WriteTaskConfig(task.BaseWriteConfig):
    """Configuration for an OPC UA write task.

    Inherits common write task fields (device, auto_start) from
    BaseWriteTaskConfig and adds OPC UA-specific channel configuration.
    OPC UA write tasks do not support state feedback, so they do not use
    state_rate or data_saving.
    """

    channels: list[WriteChannel]
    "A list of WriteChannel objects that specify which OPC UA nodes to write to."

    @field_validator("channels")
    def validate_channels_not_empty(cls, v: list[WriteChannel]) -> list[WriteChannel]:
        """Validate that at least one channel is provided."""
        if len(v) == 0:
            raise ValueError("Task must have at least one channel")
        return v


class WriteTask(task.StarterStopperMixin, task.JSONConfigMixin, task.Protocol):
    """A write task for sending commands to OPC UA devices. This task is a programmatic
    representation of the OPC UA write task configurable within the Synnax console.
    For detailed information on configuring/operating an OPC UA write task,
    see https://docs.synnaxlabs.com/reference/driver/opc-ua/write-task


    :param device: The key of the Synnax OPC UA device to write to.
    :param name: A human-readable name for the task.
    :param auto_start: Whether to start the task automatically when it is created.
    :param channels: A list of WriteChannel objects that specify which OPC UA nodes to
        write to and which Synnax channels to read command values from.
    """

    TYPE = "opc_write"
    config: WriteTaskConfig
    _internal: task.Task

    def __init__(
        self,
        internal: task.Task | None = None,
        *,
        device: device.Key = "",
        name: str = "",
        auto_start: bool = False,
        channels: list[WriteChannel] | None = None,
    ):
        if internal is not None:
            self._internal = internal
            self.config = WriteTaskConfig.model_validate(internal.config)
            return
        self._internal = task.Task(name=name, type=self.TYPE)
        self.config = WriteTaskConfig(
            device=device,
            auto_start=auto_start,
            channels=channels if channels is not None else [],
        )

    def update_device_properties(self, device_client: device.Client) -> device.Device:
        """Update device properties before task configuration."""
        dev = device_client.retrieve(key=self.config.device)
        props = dict(dev.properties)

        if "write" not in props:
            props["write"] = {"channels": {}}

        # Map node_id -> cmd_channel key for Console
        for ch in self.config.channels:
            if ch.node_id:
                props["write"]["channels"][ch.node_id] = ch.cmd_channel

        dev.properties = props
        return device_client.create(dev)


MAKE = "opc"
MODEL = "OPC UA"


class Device(device.Device):
    """
    OPC UA device configuration.

    This class extends the base Device class to provide OPC UA-specific configuration
    including connection parameters, security settings, and channel mappings.

    Example:
        >>> from synnax import opcua
        >>> dev = opcua.Device(
        ...     name="OPC UA Server",
        ...     location="opc.tcp://localhost:4840/",
        ...     rack=rack.key,
        ...     endpoint="opc.tcp://localhost:4840/",
        ...     username="admin",
        ...     password="secret",
        ...     security_mode="SignAndEncrypt",
        ...     security_policy="Basic256Sha256"
        ... )
        >>> client.devices.create(dev)

    :param endpoint: The OPC UA server endpoint URL (e.g., "opc.tcp://localhost:4840/")
    :param username: Username for authentication (optional)
    :param password: Password for authentication (optional)
    :param security_mode: Security mode - "None", "Sign", or "SignAndEncrypt" (default: "None")
    :param security_policy: Security policy - "None", "Basic128Rsa15", "Basic256", etc. (default: "None")
    :param client_cert: Client certificate for secure connections (optional)
    :param client_private_key: Client private key for secure connections (optional)
    :param server_cert: Trusted server certificate for secure connections (optional)
    """

    def __init__(
        self,
        *,
        endpoint: str,
        username: str = "",
        password: str = "",
        security_mode: SecurityMode = "None",
        security_policy: SecurityPolicy = "None",
        client_cert: str = "",
        client_private_key: str = "",
        server_cert: str = "",
        name: str = "",
        location: str = "",
        rack: int = 0,
        key: str = "",
        configured: bool = True,
    ):
        """
        Initialize an OPC UA device.

        Args:
            endpoint: The OPC UA server endpoint URL
            username: Username for authentication (optional)
            password: Password for authentication (optional)
            security_mode: Security mode - "None", "Sign", or "SignAndEncrypt"
            security_policy: Security policy name
            client_cert: Client certificate for secure connections
            client_private_key: Client private key for secure connections
            server_cert: Trusted server certificate for secure connections
            name: Human-readable name for the device
            location: Physical location or description
            rack: Rack key this device belongs to
            key: Unique key for the device (auto-generated if empty)
            configured: Whether the device has been configured (default: True for OPC UA)
        """
        # Auto-generate key if not provided
        if not key:
            key = str(uuid4())

        # Build connection properties
        # Driver expects snake_case property names (see driver/opc/connection/connection.h)
        connection = {
            "endpoint": endpoint,
            "security_mode": security_mode,
            "security_policy": security_policy,
        }

        # Optional fields - only include if non-empty
        if username:
            connection["username"] = username
        if password:
            connection["password"] = password
        if client_cert:
            connection["client_certificate"] = client_cert
        if client_private_key:
            connection["client_private_key"] = client_private_key
        if server_cert:
            connection["server_certificate"] = server_cert

        # Set properties with connection info
        props = {
            "version": "1.0.0",
            "connection": connection,
            "read": {"indexes": [], "channels": {}},
            "write": {"channels": {}},
        }

        super().__init__(
            key=key,
            location=location,
            rack=rack,
            name=name,
            make=MAKE,
            model=MODEL,
            configured=configured,
            properties=props,
        )
