"""NMEA2000 Decoder module to decode NMEA2000 messages from various input formats."""
import logging
import binascii
import os
from datetime import datetime, timedelta
from typing import Tuple, Optional, List, Dict, Callable
import can.message
from .message import IsoName, NMEA2000Message
from .consts import PhysicalQuantities
from .utils import calculate_canbus_checksum
from . import pgns as pgns_module

logger = logging.getLogger(__name__)

class FastPgnMetadata():
    """Class to store metadata for fast packet PGNs."""
    def __init__(self) -> None:
        self.frames: dict[int, bytes] = {}
        self.payload_length = 0
        self.bytes_stored = 0
        self.sequence_counter = -1

    def __repr__(self):
        return f"<FastPgnMetadata frames={len(self.frames)} payload_length={self.payload_length} bytes_stored={self.bytes_stored} sequence_counter={self.sequence_counter}>"

ISO_CLAIM_PGN = 60928
ISO_CLAIM_PGN_ID = "isoAddressClaim"

class NMEA2000Decoder():
    """NMEA2000 Decoder class to decode NMEA2000 messages from various input formats."""
    def __init__(self,
                 exclude_pgns: Optional[List[int | str]] = None,
                 include_pgns: Optional[List[int | str]] = None,
                 exclude_manufacturer_code: Optional[List[str]] = None,
                 include_manufacturer_code: Optional[List[str]] = None,
                 preferred_units: Optional[Dict[PhysicalQuantities, str]] = None,
                 dump_to_file: str | None = None,
                 dump_pgns: Optional[List[int | str]] = None,
                 build_network_map: bool = False,
                 ) -> None:
        if exclude_pgns is None:
            exclude_pgns = []
        if include_pgns is None:
            include_pgns = []
        if exclude_manufacturer_code is None:
            exclude_manufacturer_code = []
        if include_manufacturer_code is None:
            include_manufacturer_code = []
        if preferred_units is None:
            preferred_units = {}
        if dump_pgns is None:
            dump_pgns = []
        self.data: dict[str, FastPgnMetadata] = {}
        self.dump_file = None
        self.build_network_map = build_network_map
        self.started_at = datetime.now()
        if dump_to_file:
            dir_name = os.path.dirname(dump_to_file)
            if dir_name is not None and dir_name != "":
                os.makedirs(dir_name, exist_ok=True)
            self.dump_file = open(dump_to_file, 'a', encoding='utf-8')
        if not isinstance(exclude_pgns, list):
            raise ValueError("exclude_pgns must be a list")
        if not isinstance(include_pgns, list):
            raise ValueError("include_pgns must be a list")
        if len(exclude_pgns) > 0 and len(include_pgns) > 0:
            raise ValueError("Only one of exclude_pgns or include_pgns can be used")

        self.exclude_pgns, self.exclude_pgns_ids = self.split_pgn_list(exclude_pgns)
        self.include_pgns, self.include_pgns_ids = self.split_pgn_list(include_pgns)
        self.exclude_manufacturer_code = {k.lower() for k in exclude_manufacturer_code}
        self.include_manufacturer_code = {k.lower() for k in include_manufacturer_code}
        self.dump_include_pgns, self.dump_include_pgns_ids = self.split_pgn_list(dump_pgns)
        self.preferred_units = {k: v.lower() for k, v in preferred_units.items()}
        self.source_to_iso_name: dict[int, IsoName] = {}
        self.logged_unsupported_pgns: set[int] = set()

        self.iso_claim_filter = (ISO_CLAIM_PGN in self.exclude_pgns) or \
            ("isoAddressClaim" in self.exclude_pgns_ids) or \
            (len(self.include_pgns) and ISO_CLAIM_PGN not in self.include_pgns) or \
            (len(self.include_pgns_ids) and ISO_CLAIM_PGN_ID not in self.include_pgns_ids)
        if self.iso_claim_filter:
            while ISO_CLAIM_PGN in self.exclude_pgns:
                self.exclude_pgns.remove(ISO_CLAIM_PGN)
            while ISO_CLAIM_PGN_ID in self.exclude_pgns_ids:
                self.exclude_pgns_ids.remove(ISO_CLAIM_PGN_ID)
            logger.info("iso address claim will be removed later")

        logger.info("PGN filter exclude: %s, %s", self.exclude_pgns, self.exclude_pgns_ids)
        logger.info("PGN filter include: %s, %s", self.include_pgns, self.include_pgns_ids)
        logger.info("Preffered units: %s", self.preferred_units)
        logger.info("Dump location: %s, PGNs: %s", dump_to_file, dump_pgns)

    @staticmethod
    def split_pgn_list(pgn_list: list[int | str]) -> Tuple[list[int], list[str]]:
        """Split a list of PGNs into two lists: one for integers and one for strings."""
        int_list = []
        str_list = []
        if pgn_list is None:
            return int_list, str_list
        for pgn in pgn_list:
            if isinstance(pgn, int):
                int_list.append(pgn)
            elif isinstance(pgn, str):
                str_list.append(pgn.lower())
            else:
                raise ValueError(f"Invalid PGN type: {type(pgn)}. Must be int or str.")
        return int_list, str_list

    def _decode_fast_message(self, pgn, priority, src, dest, timestamp, can_data: bytes, source_iso_name: IsoName | None, raw_can_data: bytes | str) -> NMEA2000Message | None:
        """Parse a fast packet message and store the data until all frames are received."""
        fast_packet_key = f"{pgn}_{src}_{dest}"

        # Check if this PGN already has a storage structure; if not, create one
        if self.data.get(fast_packet_key) is None:
            self.data[fast_packet_key] = FastPgnMetadata()

        fast_pgn = self.data[fast_packet_key]

        # the last byte has the sequence_counter and frame_counter
        last_byte = can_data[-1]

        # Extract the sequence counter (high 3 bits) and frame counter (low 5 bits) from the last byte
        sequence_counter = (last_byte >> 5) & 0b111  # Extract high 3 bits
        frame_counter = last_byte & 0b11111  # Extract low 5 bits

        total_bytes = None

        if frame_counter != 0 and fast_pgn.payload_length == 0:
            logger.debug("Ignoring frame %s for PGN %s as first frame has not been received.", frame_counter, pgn)
            return None

        # if this is the first frame of new sequence we will start over
        if frame_counter == 0 and sequence_counter != fast_pgn.sequence_counter:

            # Extract the total number of frames from the second-to-last byte
            total_bytes = can_data[-2]

            # Start a new pgn hass structure

            fast_pgn.payload_length = total_bytes
            fast_pgn.sequence_counter = sequence_counter
            fast_pgn.bytes_stored = 0  # Reset bytes stored for a new message
            fast_pgn.frames.clear()  # Clear previous frames

            # For the first frame, exclude the last 4 hex characters (2 bytes) from the payload
            data_payload = can_data[:-2]

        else:
            if sequence_counter != fast_pgn.sequence_counter:
                logger.debug("Ignoring frame %s for PGN %s as it does not match current sequence.", sequence_counter, pgn)
                return None
            elif frame_counter in fast_pgn.frames:
                logger.debug("Frame %s for PGN %s is already stored.", frame_counter, pgn)
                return None
            else:
                # For subsequent frames, exclude the last byte from the payload
                data_payload = can_data[:-1]

        byte_length = len(data_payload)

        # Store the frame data
        fast_pgn.frames[frame_counter] = data_payload
        fast_pgn.bytes_stored += byte_length  # Update the count of bytes stored

        # Log the extracted values
        logger.debug("Sequence Counter: %s, Frame Counter: %s", sequence_counter, frame_counter)

        if total_bytes is not None:
            logger.debug("Total Payload Bytes: %s", total_bytes)

        logger.debug("Orig Payload (hex): %s, Data Payload (hex): %s", can_data, data_payload)

        logger.debug("PGN Data: %s", fast_pgn)

        # Check if all expected bytes have been stored
        if fast_pgn.bytes_stored >= fast_pgn.payload_length:

            logger.debug("All Fast packet frames collected for PGN: %d", pgn)

            # All data for this PGN has been received, proceed to publish
            combined_payload = bytes([b for idx in sorted(fast_pgn.frames) for b in fast_pgn.frames[idx][::-1]])[::-1]

            nmea = None
            if combined_payload is not None:
                logger.debug("Combined Payload (hex): %s)", combined_payload)
                nmea = self._call_decode_function(pgn, priority, src, dest, timestamp, combined_payload, source_iso_name, raw_can_data)

            # Reset the structure for this PGN
            del self.data[fast_packet_key]
            return nmea

        logger.debug("Waiting for %s more bytes.", fast_pgn.payload_length - fast_pgn.bytes_stored)
        return None

    def decode_actisense_string(self, actisense_string: str) -> NMEA2000Message | None:
        """Process an Actisense packet string and extract the PGN, source ID, and CAN data. Based on: https://actisense.com/knowledge-base/nmea-2000/w2k-1-nmea-2000-to-wifi-gateway/nmea-2000-ascii-output-format/"""
        # Split the Actisense string by spaces
        parts = actisense_string.split()

        if len(parts) < 3:
            raise ValueError("Invalid Actisense string format")

        if not parts[0].startswith("A"):
            raise ValueError("Invalid format: should start with 'A'")

        # Extract the timestamp from the first part
        seconds, milliseconds = map(int, parts[0][1:].split("."))
        offset = timedelta(seconds=seconds, milliseconds=milliseconds)
        timestamp = datetime.now() + offset

        # Extract the priority, destination, and source from the second part
        n = int(parts[1], 16)
        priority = n & 0xF
        dest = (n >> 4) & 0xFF
        src = (n >> 12) & 0xFF

        # Extract the PGN from the third part
        pgn = int(parts[2], 16)

        # Extract the CAN data from the remaining parts
        # Convert to bytes
        bytes_data = bytes.fromhex(parts[3])

        # Reverse the byte order
        reversed_bytes = bytes_data[::-1]

        # Log the extracted information
        logger.debug("Priority: %s, Destination: %s, Source: %s, PGN: %s, CAN Data: %s", priority, dest, src, pgn, reversed_bytes)

        return self._decode(pgn, priority, src, dest, timestamp, bytes(reversed_bytes), bytes_data, True)

    def decode_yacht_devices_string(self, yd_string: str) -> NMEA2000Message | None:
        """Process an Yacht Devices string and extract the PGN, source ID, and CAN data. Based on: https://www.yachtd.com/downloads/ydwg02.pdf page 62-63"""
        # Split the Actisense string by spaces
        parts = yd_string.split()

        if len(parts) < 4:
            raise ValueError("Invalid Yacht Devices string format")

        if parts[1] not in ["R", "T"]:
            raise ValueError("Invalid format: 2nd part should be 'R'")

        # Extract the timestamp from the first part
        timestamp = datetime.strptime(parts[0], "%H:%M:%S.%f")

        # Extract the PGN, priority, destination, and source from the second part
        msgid = int(parts[2], 16)
        pgn_id, source_id, dest, priority = NMEA2000Decoder._extract_header(msgid)

        # Extract the CAN data from the remaining parts
        can_data = parts[3:][::-1]
        can_data_bytes = [int(byte, 16) for byte in can_data]

        # Log the extracted information
        logger.debug("Priority: %s, Destination: %s, Source: %s, PGN: %s, CAN Data: %s", priority, dest, source_id, pgn_id, can_data_bytes)

        return self._decode(pgn_id, priority, source_id, dest, timestamp, bytes(can_data_bytes), yd_string)

    def decode_basic_string(self, basic_string: str, already_combined: bool = False) -> NMEA2000Message | None:
        """Process an Actisense packet string and extract the PGN, source ID, and CAN data."""
        # Split the Actisense string by spaces
        parts = basic_string.split(",")

        if len(parts) < 7: # should have at least one data bytes probably
            raise ValueError("Invalid string format")

        # Extract the fields
        if parts[0].endswith("Z"):
            timestamp = datetime.strptime(parts[0], "%Y-%m-%dT%H:%M:%S.%fZ")
        else:
            timestamp = datetime.strptime(parts[0], "%Y-%m-%d-%H:%M:%S.%f")
        priority = int(parts[1])
        pgn_id = int(parts[2])
        src = int(parts[3])
        dest = int(parts[4])
        length = int(parts[5])
        # Extract the CAN data from the remaining parts
        can_data = parts[6:6 + length][::-1]
        can_data_bytes = [int(byte, 16) for byte in can_data]
        # Log the extracted information
        logger.debug("Priority: %s, Destination: %s, Source: %s, PGN: %s, CAN Data: %s", priority, dest, src, pgn_id, can_data_bytes)

        # not calling _decode as in this format the fast frames are already combined
        return self._decode(pgn_id, priority, src, dest, timestamp, bytes(can_data_bytes), basic_string,already_combined)

    @staticmethod
    def _extract_header(frame_id_int: int) -> Tuple[int, int, int, int]:
        """
        Extracts PGN, source ID, destination, and priority from a 29-bit CAN frame ID.
        Returns a tuple of (pgn_id, source_id, dest, priority).
        based on the 29 bits (ID0 - ID28) in https://canboat.github.io/canboat/canboat.html
        """
        source_id = frame_id_int & 0xFF             # bits 0-7 = 8 bits
        pgn_id_raw = (frame_id_int >> 8) & 0x3FFFF  # bits 8-25 = 18 bits
        priority = (frame_id_int >> 26) & 0x07      # bits 26-28 = 3 bits

        dp = (pgn_id_raw >> 16) & 0x3    # bits 16-17
        pf = (pgn_id_raw >> 8) & 0xFF    # bits 8-15
        ps = pgn_id_raw & 0xFF           # bits 0-7

        if pf < 0xF0:
            # PDU1 format: PS is destination address
            dest = ps
            pgn_id = (dp << 16) | (pf << 8)
        else:
            # PDU2 format: broadcast, destination is always 255
            dest = 255
            pgn_id = (dp << 16) | (pf << 8) | ps

        return pgn_id, source_id, dest, priority

    def decode_tcp(self, packet: bytes) -> NMEA2000Message | None:
        """Tested with ECAN devices. Process a single packet and extract the PGN, source ID, and CAN data."""

        # First byte has the data length in the lowest 4 bits
        type_byte = packet[0]
        data_length = type_byte & 0x0F  # last 4 bits represent the data length

        # Extract the frame ID
        frame_id = packet[1:5]
        # Convert frame_id bytes to an integer
        frame_id_int = int.from_bytes(frame_id, byteorder='big')
        # Parse it
        pgn_id, source_id, dest, priority = NMEA2000Decoder._extract_header(frame_id_int)

        # Extract and reverse the CAN data
        can_data = packet[5:5 + data_length][::-1]

        # Log the extracted information including the combined string
        logger.debug("PGN ID: %s, Frame ID: %s, CAN Data: %s, Source ID: %s",
            pgn_id,
            binascii.hexlify(frame_id).decode('ascii'),
            can_data,
            source_id)

        return self._decode(pgn_id, priority, source_id, dest, datetime.now(), bytes(can_data), packet)

    def decode_usb(self, packet: bytes) -> NMEA2000Message | None:
        """Tested with Waveshare-usb-a device. Process a single packet and extract the PGN, source ID, and CAN data."""
        if packet[0] != 0xaa or packet[1] != 0x55:
            raise ValueError("Packet does not have the right prefix and suffix")

        if len(packet) != 20:
            logger.warning("Packet is not 20 bytes long: %s", packet.hex())
            return None

        # Extract the frame ID
        frame_id = packet[5:9]
        # Convert frame_id bytes to an integer
        frame_id_int = int.from_bytes(frame_id, byteorder='little')
        # Parse it
        pgn_id, source_id, dest, priority = NMEA2000Decoder._extract_header(frame_id_int)

        checksum = calculate_canbus_checksum(packet)
        if checksum != packet[19]:
            logger.warning("Invalid checksum: %s (expected: %s), PGN ID: %s, source: %s, dest: %s, priority: %s, packet: %s",
                checksum,
                packet[19],
                pgn_id,
                source_id,
                dest,
                priority,
                packet.hex())
            return None

        # Extract and reverse the CAN data
        data_length = packet[9]
        can_data = packet[10:10 + data_length][::-1]

        # Log the extracted information including the combined string
        logger.debug("Got valid packet. PGN ID: %s, source: %s, dest: %s, priority: %s, CAN Data: %s",
            pgn_id,
            source_id,
            dest,
            priority,
            can_data.hex())

        return self._decode(pgn_id, priority, source_id, dest, datetime.now(), bytes(can_data), packet)

    def decode_python_can(self, msg: "can.message.Message") -> "NMEA2000Message | None":
        """Decode a python-can Message object.

        Process a single python-can Message and extract the PGN, source ID, and CAN data.
        python-can already parses the message into a Message object with arbitration_id
        and data fields. The remaining work is decoding the 29-bit arbitration ID and
        stitching together fast data messages.
        """
        pgn_id, source_id, dest, priority = NMEA2000Decoder._extract_header(msg.arbitration_id)

        # python-can data is already in network byte order; reverse to match internal convention
        can_data = bytes(msg.data)[::-1]

        timestamp = datetime.fromtimestamp(msg.timestamp) if msg.timestamp else datetime.now()

        return self._decode(pgn_id, priority, source_id, dest, timestamp, can_data, msg.data)

    @staticmethod
    def is_fast_pgn(pgn_id: int) -> bool | None:
        """Check if a PGN is a fast packet PGN by looking for the is_fast_pgn_{pgn_id} function in the pgns module."""
        is_fast_func_name = f'is_fast_pgn_{pgn_id}'
        is_fast_func: Callable[[], bool] | None = getattr(pgns_module, is_fast_func_name, None)

        if is_fast_func and callable(is_fast_func):
            is_fast: bool = is_fast_func()  # pylint: disable=not-callable
            logger.debug("Is fast PGN: %s", is_fast)
            return is_fast
        else:
            return None

    def _log_unsupported_pgn_once(self, pgn_id: int) -> None:
        """Log unsupported PGN message only once per PGN."""
        if pgn_id not in self.logged_unsupported_pgns:
            logger.warning("Not supporrted PGN: %d", pgn_id)
            self.logged_unsupported_pgns.add(pgn_id)

    def _decode(self, pgn: int, priority: int, source_id: int, destination_id: int, timestamp: datetime, can_data: bytes, raw_can_data: bytes | str, already_combined: bool = False) -> NMEA2000Message | None:
        """Decode a single PGN message."""

        source_iso_name = None
        # Check if the PGN should be excluded or included
        if pgn != ISO_CLAIM_PGN: # The ISO_CLAIM_PGN should bypass this check so we can build the map later
            if pgn in self.exclude_pgns:
                logger.debug("Excluding PGN: %s", pgn)
                return None
            if len(self.include_pgns) > 0 and len(self.include_pgns_ids) == 0 and pgn not in self.include_pgns:
                logger.debug("Excluding (by include) PGN: %s", pgn)
                return None

            source_iso_name = self.source_to_iso_name.get(source_id, None)
            if source_iso_name is None and self.build_network_map:
                if self.started_at > datetime.now() - timedelta(minutes=10):
                    logger.debug("No ISO name found for source %s in PGN id %s. Skipping the message for now.", source_id, pgn)
                    return None
                logger.warning("No ISO name found for source %s in PGN id %s for too long. Will process it anyhow.", source_id, pgn)

            if source_iso_name is not None and source_iso_name.manufacturer_code is not None:
                # Check if the PGN should be excluded or included based on manufacturer
                manufacturer_code = source_iso_name.manufacturer_code.lower()
                if manufacturer_code in self.exclude_manufacturer_code:
                    logger.debug("Excluding PGN: %s based on manufacturer code %s", pgn, source_iso_name.manufacturer_code)
                    return None
                if len(self.include_manufacturer_code) > 0 and manufacturer_code not in self.include_manufacturer_code:
                    logger.debug("Excluding (by include) PGN: %s based on manufacturer code %s", pgn, source_iso_name.manufacturer_code)
                    return None

        is_fast = False
        if not already_combined:
            is_fast = NMEA2000Decoder.is_fast_pgn(pgn)
        if is_fast is None:
            self._log_unsupported_pgn_once(pgn)
            return None

        if is_fast:
            return self._decode_fast_message(pgn, priority, source_id, destination_id, timestamp, can_data, source_iso_name, raw_can_data)
        else:
            return self._call_decode_function(pgn, priority, source_id, destination_id, timestamp, can_data, source_iso_name, raw_can_data)

    def _call_decode_function(self, pgn:int, priority: int, src: int, dest: int, timestamp: datetime, data:bytes, source_iso_name: IsoName | None, raw_can_data: bytes | str) -> NMEA2000Message | None:
        decode_func_name = f'decode_pgn_{pgn}'
        decode_func: Callable[[int], NMEA2000Message] | None = getattr(pgns_module, decode_func_name, None)

        if not decode_func or not callable(decode_func):
            logger.error("No decoding function found for PGN: %s. It should be there as we found the is_fast func", pgn)
            return None

        data_int = int.from_bytes(data, "big")
        nmea2000_message: NMEA2000Message | None = decode_func(data_int) # pylint: disable=not-callable
        if nmea2000_message is None:
            logger.debug("No sub-decoding function found for PGN: %s", pgn)
            return None

        # Handle ISO Address Claim messages and enrichment
        if nmea2000_message.PGN == ISO_CLAIM_PGN:
            # In this message the data is a 64 bit unique NAME which is stable between network restarts
            old_source = self.source_to_iso_name.get(src, None)
            if old_source is not None and old_source.name == data_int:
                logger.debug("Using existing ISO_CLAIM_PGN for source %s", src)
                source_iso_name = old_source
            else:
                new_source = IsoName(nmea2000_message, data_int)
                logger.info("Using new ISO_CLAIM_PGN for source %s: %s", src, new_source)
                source_iso_name = self.source_to_iso_name[src] = new_source
            if self.iso_claim_filter:
                logger.debug("Excluding ISO_CLAIM_PGN")
                return None

        # Check if the PGN should be excluded or included by ID
        msg_id = nmea2000_message.id.lower()
        if msg_id in self.exclude_pgns_ids:
            logger.debug("Excluding PGN by id: %s", nmea2000_message.id)
            return None
        if len(self.include_pgns) > 0 and msg_id not in self.include_pgns and len(self.include_pgns_ids) > 0 and msg_id not in self.include_pgns_ids:
            logger.debug("Excluding (by include) PGN %d by id: %s", pgn, nmea2000_message.id)
            return None

        nmea2000_message.add_data(src, dest, priority, timestamp, source_iso_name, self.build_network_map, raw_can_data)
        nmea2000_message.apply_preferred_units(self.preferred_units)

        # Handle dump to file
        if (self.dump_file is not None) and (len(self.dump_include_pgns)+len(self.dump_include_pgns_ids) == 0 or nmea2000_message.PGN in self.dump_include_pgns or nmea2000_message.id in self.dump_include_pgns_ids):
            json_str = nmea2000_message.to_json() + "\n"
            self.dump_file.write(json_str)

        return nmea2000_message

    def close(self):
        """Close the dump_file file if it is open."""
        if self.dump_file:
            self.dump_file.close()
            self.dump_file = None
            logger.info("dump_file file has been closed.")

    def __enter__(self):
        """Enter the runtime context related to this object."""
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        """Exit the runtime context and clean up resources."""
        self.close()
