# Load and dump a CAN database in SYM format.

import collections
from itertools import groupby
import re
import logging
from collections import OrderedDict as odict
from decimal import Decimal
from typing import Callable, Iterator, List, Optional as TypingOptional

import textparser
from textparser import Sequence
from textparser import choice
from textparser import ZeroOrMore
from textparser import ZeroOrMoreDict
from textparser import DelimitedList
from textparser import tokenize_init
from textparser import Token
from textparser import TokenizeError
from textparser import Optional
from textparser import Any

from ..signal import Signal
from ..signal import NamedSignalValue
from ..signal import Decimal as SignalDecimal
from ..message import Message
from ..internal_database import InternalDatabase

from .utils import num
from ...utils import SORT_SIGNALS_DEFAULT, type_sort_signals, sort_signals_by_start_bit
from ...errors import ParseError


LOGGER = logging.getLogger(__name__)

# PCAN Symbol Editor will fail to open a SYM File with signals of a longer length
MAX_SIGNAL_NAME_LENGTH = 32
# If a message is in the SEND section of a SYM file, it is sent by the ECU
SEND_MESSAGE_SENDER = 'ECU'
# If a message is in the RECEIVE section of a SYM file, it is sent by the Peripheral devices
RECEIVE_MESSAGE_SENDER = 'Peripherals'


class Parser60(textparser.Parser):
    """Create the SYM 6.0 parser.

    """

    KEYWORDS = set([
        'FormatVersion',
        'Title',
        'UniqueVariables',
        'FloatDecimalPlaces',
        'BRS',
        'Enum',
        'Sig',
        'ID',
        'Len',
        'Mux',
        'CycleTime',
        'Timeout',
        'MinInterval',
        'Color',
        'Var',
        'Type'
    ])

    def tokenize(self, string):
        names = {
            'LPAREN':      '(',
            'RPAREN':      ')',
            'LBRACE':      '[',
            'RBRACE':      ']',
            'COMMA':       ',',
            'ASSIGN':      '=',
            'ENUMS':       '{ENUMS}',
            'SIGNALS':     '{SIGNALS}',
            'SEND':        '{SEND}',
            'RECEIVE':     '{RECEIVE}',
            'SENDRECEIVE': '{SENDRECEIVE}',
            'U':           '/u:',
            'F':           '/f:',
            'O':           '/o:',
            'MIN':         '/min:',
            'MAX':         '/max:',
            'SPN':         '/spn:',
            'D':           '/d:',
            'LN':          '/ln:',
            'E':           '/e:',
            'P':           '/p:',
            'M':           '-m',
            'H':           '-h',
            'B':           '-b',
            'S':           '-s',
            'T':           '-t',
            'V':           '-v',
            'DP':          '-p'
        }

        re_string = r'"(\\"|[^"])*?"'

        token_specs = [
            ('SKIP',               r'[ \r\n\t]+'),
            ('COMMENT',            r'//.*?\n'),
            ('HEXNUMBER',          r'-?\d+\.?[0-9A-F]*([eE][+-]?\d+)?(h)'),
            ('NUMBER',             r'-?\d+\.?[0-9A-F]*([eE][+-]?\d+)?'),
            ('STRING',             re_string),
            ('U',                  r'/u:({}|\S+)'.format(re_string)),
            ('F',                  r'/f:'),
            ('O',                  r'/o:'),
            ('MIN',                r'/min:'),
            ('MAX',                r'/max:'),
            ('SPN',                r'/spn:'),
            ('D',                  r'/d:'),
            ('LN',                 r'/ln:'),
            ('E',                  r'/e:'),
            ('P',                  r'/p:'),
            ('M',                  r'\-m'),
            ('H',                  r'\-h'),
            ('B',                  r'\-b'),
            ('S',                  r'\-s'),
            ('T',                  r'\-t'),
            ('V',                  r'\-v'),
            ('DP',                 r'\-p'),
            ('LPAREN',             r'\('),
            ('RPAREN',             r'\)'),
            ('LBRACE',             r'\['),
            ('RBRACE',             r'\]'),
            ('COMMA',              r','),
            ('ASSIGN',             r'='),
            ('ENUMS',              r'\{ENUMS\}'),
            ('SIGNALS',            r'\{SIGNALS\}'),
            ('SEND',               r'\{SEND\}'),
            ('RECEIVE',            r'\{RECEIVE\}'),
            ('SENDRECEIVE',        r'\{SENDRECEIVE\}'),
            ('WORD',               r'[^\s=\(\]\-]+'),
            ('MISMATCH',           r'.')
        ]

        tokens, token_regex = tokenize_init(token_specs)

        for mo in re.finditer(token_regex, string, re.DOTALL):
            kind = mo.lastgroup

            if kind == 'SKIP':
                pass
            elif kind == 'STRING':
                value = mo.group(kind)[1:-1].replace('\\"', '"')
                tokens.append(Token(kind, value, mo.start()))
            elif kind != 'MISMATCH':
                value = mo.group(kind)

                if value in self.KEYWORDS:
                    kind = value

                if kind in names:
                    kind = names[kind]

                tokens.append(Token(kind, value, mo.start()))
            else:
                raise TokenizeError(string, mo.start())

        return tokens

    def grammar(self):
        word = choice('WORD', *list(self.KEYWORDS))
        version = Sequence('FormatVersion', '=', 'NUMBER', 'COMMENT')
        title = Sequence('Title' , '=', 'STRING')
        unique_variables = Sequence('UniqueVariables' , '=', word)
        float_decimal_places = Sequence('FloatDecimalPlaces' , '=', 'NUMBER')
        bit_rate_switch = Sequence('BRS' , '=', word)

        enum_value = Sequence('NUMBER', '=', 'STRING')
        delim = Sequence(',', Optional('COMMENT'))
        enum = Sequence('Enum', '=', word,
                        '(', Optional(DelimitedList(enum_value, delim=delim)), ')',
                        Optional('COMMENT'))

        sig_unit = '/u:'
        sig_factor = Sequence('/f:', 'NUMBER')
        sig_offset = Sequence('/o:', 'NUMBER')
        sig_min = Sequence('/min:', 'NUMBER')
        sig_max = Sequence('/max:', 'NUMBER')
        sig_spn = Sequence('/spn:', 'NUMBER')
        sig_default = Sequence('/d:', choice('NUMBER', 'WORD'))
        sig_long_name = Sequence('/ln:', 'STRING')
        sig_enum = Sequence('/e:', word)
        sig_places = Sequence('/p:', 'NUMBER')

        signal = Sequence('Sig', '=', Any(), word,
                          Optional('NUMBER'),
                          Optional(choice('-h', '-b')),
                          Optional('-m'),
                          ZeroOrMore(choice(sig_unit,
                                            sig_factor,
                                            sig_offset,
                                            sig_min,
                                            sig_max,
                                            sig_default,
                                            sig_long_name,
                                            sig_enum,
                                            sig_places,
                                            sig_spn)),
                          Optional('COMMENT'))

        variable = Sequence('Var', '=', Any(), word,
                            'NUMBER', ',', 'NUMBER',
                            ZeroOrMore(choice('-v', '-m', '-s', '-h')),
                            ZeroOrMore(choice(sig_unit,
                                              sig_factor,
                                              sig_offset,
                                              sig_min,
                                              sig_max,
                                              sig_default,
                                              sig_long_name,
                                              sig_enum,
                                              sig_places)),
                            Optional('COMMENT'))

        symbol = Sequence('[', Any(), ']',
                          ZeroOrMoreDict(choice(
                              Sequence('ID', '=', 'HEXNUMBER',
                                       Optional('HEXNUMBER'),
                                       Optional('COMMENT')),
                              Sequence('Len', '=', 'NUMBER'),
                              Sequence('Mux', '=', Any(), 'NUMBER', ',',
                                       'NUMBER', choice('NUMBER', 'HEXNUMBER'),
                                       ZeroOrMore(choice('-t', '-m')),
                                       Optional('COMMENT')),
                              Sequence('CycleTime', '=', 'NUMBER', Optional('-p')),
                              Sequence('Timeout', '=', 'NUMBER'),
                              Sequence('MinInterval', '=', 'NUMBER'),
                              Sequence('Color', '=', 'HEXNUMBER'),
                              variable,
                              Sequence('Sig', '=', Any(), 'NUMBER'),
                              Sequence('Type', '=', Any()))))

        enums = Sequence('{ENUMS}', ZeroOrMore(choice(enum, 'COMMENT')))
        signals = Sequence('{SIGNALS}', ZeroOrMore(choice(signal, 'COMMENT')))
        send = Sequence('{SEND}', ZeroOrMore(choice(symbol, 'COMMENT')))
        receive = Sequence('{RECEIVE}', ZeroOrMore(choice(symbol, 'COMMENT')))
        sendreceive = Sequence('{SENDRECEIVE}', ZeroOrMore(choice(symbol, 'COMMENT')))

        section = choice(enums,
                         signals,
                         send,
                         receive,
                         sendreceive)

        grammar = Sequence(Optional('COMMENT'),
                           version,
                           ZeroOrMore(choice(unique_variables,
                                             float_decimal_places,
                                             title,
                                             bit_rate_switch)),
                           ZeroOrMore(section))

        return grammar


def _get_section_tokens(tokens, name):
    rows = []
    for section in tokens[3]:
        if section[0] == name:
            rows.extend([row for row in section[1] if isinstance(row, list)])

    return rows


def _load_comment(tokens):
    return tokens[3:].rstrip('\r\n')


def _get_enum(enums, name):
    try:
        return enums[name]
    except KeyError:
        raise ParseError("Enum '{}' is not defined.".format(name))


def _load_enums(tokens):
    section = _get_section_tokens(tokens, '{ENUMS}')
    all_enums = {}

    for _, _, name, _, values, _, _ in section:
        if values:
            values = values[0]

        enum = odict()
        for v in values:
            value = num(v[0])
            value_name = v[2]
            enum[value] = NamedSignalValue(value, value_name)

        all_enums[name] = enum

    return all_enums


def _load_signal_type_and_length(type_, tokens, enums):
    # Default values.
    is_signed = False
    is_float = False
    length = 0
    enum = None
    minimum = None
    maximum = None
    decimal = SignalDecimal()

    if type_ == 'signed':
        is_signed = True
        length = int(tokens[0])
    elif type_ == 'unsigned':
        length = int(tokens[0])
    elif type_ == 'float':
        is_float = True
        length = 32
    elif type_ == 'double':
        is_float = True
        length = 64
    elif type_ == 'bit':
        # As unsigned integer for now.
        length = 1
        minimum = 0
        maximum = 1
        decimal.minimum = Decimal('0')
        decimal.maximum = Decimal('1')
    elif type_ == 'char':
        # As unsigned integer for now.
        length = 8
    elif type_ in ['string', 'raw']:
        # As unsigned integer for now.
        length = int(tokens[0])
    else:
        # Enum. As unsigned integer for now.
        length = int(tokens[0])
        enum = _get_enum(enums, type_)

    return is_signed, is_float, length, enum, minimum, maximum, decimal


def _load_signal_attributes(tokens, enum, enums, minimum, maximum, decimal, spn):
    # Default values.
    factor = 1
    offset = 0
    unit = None
    decimal.scale = Decimal(factor)
    decimal.offset = Decimal(offset)

    for item in tokens:
        if isinstance(item, list):
            key, value = item

            if key == '/f:':
                factor = num(value)
                decimal.scale = Decimal(value)
            elif key == '/o:':
                offset = num(value)
                decimal.offset = Decimal(value)
            elif key == '/min:':
                minimum = num(value)
                decimal.minimum = Decimal(value)
            elif key == '/max:':
                maximum = num(value)
                decimal.maximum = Decimal(value)
            elif key == '/e:':
                enum = _get_enum(enums, value)
            elif key == '/spn:':
                spn = int(value)
            else:
                LOGGER.debug("Ignoring unsupported message attribute '%s'.", key)
        elif item.startswith('/u:"'):
            unit = item[4:-1]
        elif item.startswith('/u:'):
            unit = item[3:]
        else:
            raise ParseError('Iternal error {}.'.format(item))

    return unit, factor, offset, enum, minimum, maximum, decimal, spn


def _load_signal(tokens, enums):
    # Default values.
    name = tokens[2]
    byte_order = 'little_endian'
    comment = None
    spn = None

    # Type and length.
    (is_signed,
     is_float,
     length,
     enum,
     minimum,
     maximum,
     decimal) = _load_signal_type_and_length(tokens[3],
                                             tokens[4],
                                             enums)

    # Byte order.
    if tokens[6] == ['-m']:
        byte_order = 'big_endian'

    # Comment.
    if tokens[8]:
        comment = _load_comment(tokens[8][0])

    # The rest.
    unit, factor, offset, enum, minimum, maximum, decimal, spn = _load_signal_attributes(
        tokens[7],
        enum,
        enums,
        minimum,
        maximum,
        decimal,
        spn)

    return Signal(name=name,
                  start=offset,
                  length=length,
                  receivers=[],
                  byte_order=byte_order,
                  is_signed=is_signed,
                  scale=factor,
                  offset=offset,
                  minimum=minimum,
                  maximum=maximum,
                  unit=unit,
                  choices=enum,
                  comment=comment,
                  is_multiplexer=False,
                  is_float=is_float,
                  decimal=decimal,
                  spn=spn)


def _load_signals(tokens, enums):
    section = _get_section_tokens(tokens, '{SIGNALS}')
    signals = {}

    for signal in section:
        signal = _load_signal(signal, enums)
        signals[signal.name] = signal

    return signals


def _load_message_signal(tokens,
                         signals,
                         multiplexer_signal,
                         multiplexer_ids):
    signal = signals[tokens[2]]
    start = int(tokens[3])
    start = _convert_start(start, signal.byte_order)

    return Signal(name=signal.name,
                  start=start,
                  length=signal.length,
                  receivers=signal.receivers,
                  byte_order=signal.byte_order,
                  is_signed=signal.is_signed,
                  scale=signal.scale,
                  offset=signal.offset,
                  minimum=signal.minimum,
                  maximum=signal.maximum,
                  unit=signal.unit,
                  choices=signal.choices,
                  comment=signal.comment,
                  is_multiplexer=signal.is_multiplexer,
                  multiplexer_ids=multiplexer_ids,
                  multiplexer_signal=multiplexer_signal,
                  is_float=signal.is_float,
                  decimal=signal.decimal,
                  spn=signal.spn)

def _convert_start(start, byte_order):
    if byte_order == 'big_endian':
        start = (8 * (start // 8) + (7 - (start % 8)))
    return start

def _load_message_variable(tokens,
                           enums,
                           multiplexer_signal,
                           multiplexer_ids):
    # Default values.
    name = tokens[2]
    byte_order = 'little_endian'
    start = int(tokens[4])
    comment = None
    spn = None

    # Type and length.
    (is_signed,
     is_float,
     length,
     enum,
     minimum,
     maximum,
     decimal) = _load_signal_type_and_length(tokens[3],
                                             [tokens[6]],
                                             enums)

    # Byte order.
    if '-m' in tokens[7]:
        byte_order = 'big_endian'

    # Comment.
    if tokens[9]:
        comment = _load_comment(tokens[9][0])

    # The rest.
    unit, factor, offset, enum, minimum, maximum, decimal, spn = _load_signal_attributes(
        tokens[8],
        enum,
        enums,
        minimum,
        maximum,
        decimal,
        spn)

    start = _convert_start(start, byte_order)

    return Signal(name=name,
                  start=start,
                  length=length,
                  receivers=[],
                  byte_order=byte_order,
                  is_signed=is_signed,
                  scale=factor,
                  offset=offset,
                  minimum=minimum,
                  maximum=maximum,
                  unit=unit,
                  choices=enum,
                  comment=comment,
                  is_multiplexer=False,
                  multiplexer_ids=multiplexer_ids,
                  multiplexer_signal=multiplexer_signal,
                  is_float=is_float,
                  decimal=decimal,
                  spn=spn)


def _load_message_signals_inner(message_tokens,
                                signals,
                                enums,
                                multiplexer_signal=None,
                                multiplexer_ids=None):
    return [
        _load_message_signal(signal,
                             signals,
                             multiplexer_signal,
                             multiplexer_ids)
        for signal in message_tokens[3].get('Sig', [])
    ] + [
        _load_message_variable(variable,
                               enums,
                               multiplexer_signal,
                               multiplexer_ids)
        for variable in message_tokens[3].get('Var', [])
    ]


def _load_muxed_message_signals(message_tokens,
                                message_section_tokens,
                                signals,
                                enums):
    def get_mutliplexer_ids(mux_tokens):
        base = 10
        mux_id = mux_tokens[6]
        if mux_id.endswith('h'):
            base = 16
            mux_id = mux_id[:-1]

        return [int(mux_id, base=base)]

    mux_tokens = message_tokens[3]['Mux'][0]
    multiplexer_signal = mux_tokens[2]
    if '-m' in mux_tokens[7]:
        byte_order = 'big_endian'
    else:
        byte_order = 'little_endian'
    start = int(mux_tokens[3])
    start = _convert_start(start, byte_order)
    if mux_tokens[8]:
        comment = _load_comment(mux_tokens[8][0])
    else:
        comment = None
    result = [
        Signal(name=multiplexer_signal,
               start=start,
               length=int(mux_tokens[5]),
               byte_order=byte_order,
               is_multiplexer=True,
               decimal=SignalDecimal(Decimal(1), Decimal(0)),
               comment=comment,
        )
    ]

    multiplexer_ids = get_mutliplexer_ids(mux_tokens)
    result += _load_message_signals_inner(message_tokens,
                                          signals,
                                          enums,
                                          multiplexer_signal,
                                          multiplexer_ids)

    for tokens in message_section_tokens:
        if tokens[1] == message_tokens[1] and tokens != message_tokens:
            mux_tokens = tokens[3]['Mux'][0]
            multiplexer_ids = get_mutliplexer_ids(mux_tokens)
            result += _load_message_signals_inner(tokens,
                                                  signals,
                                                  enums,
                                                  multiplexer_signal,
                                                  multiplexer_ids)

    return result


def _is_multiplexed(message_tokens):
    return 'Mux' in message_tokens[3]


def _load_message_signals(message_tokens,
                          message_section_tokens,
                          signals,
                          enums):
    if _is_multiplexed(message_tokens):
        return _load_muxed_message_signals(message_tokens,
                                           message_section_tokens,
                                           signals,
                                           enums)
    else:
        return _load_message_signals_inner(message_tokens,
                                           signals,
                                           enums)


def _get_senders(section_name: str) -> List[str]:
    """Generates a list of senders for a message based on the Send, Receive or Send/Receive
    flag defined in the SYM file. Since the Message object only has a senders property on it,
    it is easiest to translate Send flags into a sender named 'ECU', and translate Receive flags
    into a sender named 'Peripherals'. This is not the cleanest representation of the data,
    however, SYM files are unique in only having a Send, Receive or Send/Receive Direction. Most
    other file formats specify a list of custom-named sending devices
    """
    if section_name == '{SEND}':
        return [SEND_MESSAGE_SENDER]
    elif section_name == '{RECEIVE}':
        return [RECEIVE_MESSAGE_SENDER]
    elif section_name == '{SENDRECEIVE}':
        return [SEND_MESSAGE_SENDER, RECEIVE_MESSAGE_SENDER]
    else:
        raise ValueError(f'Unexpected message section named {section_name}')

def _load_message(frame_id,
                  is_extended_frame,
                  message_tokens,
                  message_section_tokens,
                  signals,
                  enums,
                  strict,
                  sort_signals,
                  section_name):
    #print(message_tokens)
    # Default values.
    name = message_tokens[1]
    length = 8
    cycle_time = None
    comment = None

    if 'Len' in message_tokens[3]:
        length = int(message_tokens[3]['Len'][0][2])

    # Cycle time.
    try:
        cycle_time = num(message_tokens[3]['CycleTime'][0][2])
    except (KeyError, IndexError):
        pass

    # Comment.
    if message_tokens[3]['ID'][0][-1]:
        comment = _load_comment(message_tokens[3]['ID'][0][-1][0])

    return Message(frame_id=frame_id,
                   is_extended_frame=is_extended_frame,
                   name=name,
                   length=length,
                   unused_bit_pattern=0xff,
                   senders=_get_senders(section_name),
                   send_type=None,
                   cycle_time=cycle_time,
                   signals=_load_message_signals(message_tokens,
                                                 message_section_tokens,
                                                 signals,
                                                 enums),
                   comment=comment,
                   bus_name=None,
                   strict=strict,
                   sort_signals=sort_signals)


def _parse_message_frame_ids(message):
    def to_int(string):
        return int(string, 16)

    def is_extended_frame(string, type):
        # Length of 9 includes terminating 'h' for hex
        return len(string) == 9 or type.lower() in ['extended', 'fdextended']

    message = message[3]

    message_id = message['ID'][0]
    minimum = to_int(message_id[2][:-1])

    if message_id[3]:
        maximum = to_int(message_id[3][0][1:-1])
    else:
        maximum = minimum

    frame_ids = range(minimum, maximum + 1)

    message_type = 'Standard'
    if 'Type' in message:
        message_type = message['Type'][0][2]

    return frame_ids, is_extended_frame(message_id[2], message_type)


def _load_message_section(section_name, tokens, signals, enums, strict, sort_signals):
    def has_frame_id(message):
        return 'ID' in message[3]

    message_section_tokens = _get_section_tokens(tokens, section_name)
    messages = []

    for message_tokens in message_section_tokens:
        if not has_frame_id(message_tokens):
            continue

        frame_ids, is_extended_frame = _parse_message_frame_ids(message_tokens)

        for frame_id in frame_ids:
            message = _load_message(frame_id,
                                    is_extended_frame,
                                    message_tokens,
                                    message_section_tokens,
                                    signals,
                                    enums,
                                    strict,
                                    sort_signals,
                                    section_name)
            messages.append(message)

    return messages


def _load_messages(tokens, signals, enums, strict, sort_signals):
    messages = _load_message_section('{SEND}', tokens, signals, enums, strict, sort_signals)
    messages += _load_message_section('{RECEIVE}', tokens, signals, enums, strict, sort_signals)
    messages += _load_message_section('{SENDRECEIVE}', tokens, signals, enums, strict, sort_signals)

    return messages


def _load_version(tokens):
    return tokens[1][2]


def _get_signal_name(signal: Signal) -> str:
    return signal.name[:MAX_SIGNAL_NAME_LENGTH]

def _get_enum_name(signal: Signal) -> str:
    """Returns the name of an enum for a signal. Returns the shortened
    signal name, plus the letter 'E', since the cantools database doesn't
    store enum names, unlike the SYM file
    """
    return f'{_get_signal_name(signal).replace(" ", "_").replace("/", "_")[:MAX_SIGNAL_NAME_LENGTH - 1]}E'

def _dump_choice(signal: Signal) -> str:
    # Example:
    # Enum=DPF_Actv_Options(0="notActive", 1="active", 2="rgnrtnNddAtmtcllyInttdActvRgnrt", 3="notAvailable")
    if not signal.choices:
        return ''

    enum_str = f'Enum={_get_enum_name(signal)}('
    is_first_choice = True
    for choice_number, choice_value in signal.choices.items():
        enum_str += f'{", " if not is_first_choice else ""}{choice_number}="{choice_value}"'
        is_first_choice = False
    enum_str += ')'
    return enum_str

def _dump_choices(database: InternalDatabase) -> str:
    choices = []
    # SYM requires unique signals
    generated_signals = set()
    for message in database.messages:
        for signal in message.signals:
            if signal.name not in generated_signals:
                generated_signals.add(signal.name)
                new_choice = _dump_choice(signal)
                if new_choice:
                    choices.append(new_choice)
    
    if choices:
        return '{ENUMS}\n' + '\n'.join(choices)
    else:
        return ''

def _get_signal_type(signal: Signal) -> str:
    if signal.is_float:
        if signal.length == 64:
            return 'double'
        else:
            return 'float'
    else:
        if signal.is_signed:
            return 'signed'
        else:
            if signal.length == 1 and signal.minimum == 0 and signal.maximum == 1:
                return 'bit'

            return 'unsigned'

def _dump_signal(signal: Signal) -> str:
    # Example:
    # Sig=alt_current unsigned 16 /u:A /f:0.05 /o:-1600 /max:1676.75 /d:0 // Alternator Current
    signal_str = f'Sig="{_get_signal_name(signal)}" {_get_signal_type(signal)} {signal.length}'
    if signal.byte_order == 'big_endian':
        signal_str += ' -m'
    if signal.unit:
        signal_str += f' /u:"{signal.unit}"'
    if signal.scale and signal.scale != 1:
        signal_str += f' /f:{signal.scale}'
    if signal.offset and signal.offset != 0:
        signal_str += f' /o:{signal.offset}'
    if signal.maximum is not None:
        signal_str += f' /max:{signal.maximum}'
    if signal.minimum is not None:
        signal_str += f' /min:{signal.minimum}'
    if signal.spn and signal.spn != 0:
        signal_str += f' /spn:{signal.spn}'
    if signal.choices:
        signal_str += f' /e:{_get_enum_name(signal)}'
    if signal.comment:
        signal_str += f' // {signal.comment}'

    return signal_str

def _dump_signals(database: InternalDatabase, sort_signals: TypingOptional[Callable[[List[Signal]], List[Signal]]]) -> str:
    signal_dumps = []
    # SYM requires unique signals
    generated_signals = set()
    for message in database.messages:
        if sort_signals:
            signals = sort_signals(message.signals)
        else:
            signals = message.signals
        for signal in signals:
            if signal.name not in generated_signals:
                generated_signals.add(signal.name)
                signal_dumps.append(_dump_signal(signal))

    if signals:
        return '{SIGNALS}\n' + '\n'.join(signal_dumps)
    else:
        return ''

def _dump_message(message: Message, signals: List[Signal], min_frame_id: TypingOptional[int], max_frame_id: TypingOptional[int] = None,
                  multiplexer_id: TypingOptional[int] = None, multiplexer_signal: TypingOptional[Signal] = None) -> str:
    # Example:
    # [TestMessage]
    # ID=14A30000h
    # Type=Extended
    # Len=8
    # Sig=test_signal 0
    extended = ''
    if message.is_extended_frame:
        extended = 'Type=Extended\n'
    frame_id = ''
    frame_id_newline = ''
    comment = ''
    # Frame id should be excluded for multiplexed messages after the first listed message instance
    if min_frame_id is not None:
        if message.is_extended_frame:
            frame_id = f'ID={min_frame_id:08X}h'
        else:
            frame_id = f'ID={min_frame_id:03X}h'
        frame_id_newline = '\n'
        if message.comment is not None:
            comment = f' // {message.comment}'
    frame_id_range = ''
    if max_frame_id is not None:
        if message.is_extended_frame:
            frame_id_range = f'-{max_frame_id:08X}h'
        else:
            frame_id_range = f'-{max_frame_id:03X}h'
    message_str = f'["{message.name}"]\n{frame_id}{frame_id_range}{comment}{frame_id_newline}{extended}Len={message.length}\n'
    if message.cycle_time:
        message_str += f'CycleTime={message.cycle_time}\n'
    if multiplexer_id is not None and multiplexer_signal is not None:
        m_flag = ''
        if multiplexer_signal.byte_order == 'big_endian':
            m_flag = '-m'
        hex_multiplexer_id = format(multiplexer_id, 'x').upper()
        message_str += f'Mux="{hex_multiplexer_id}" {_convert_start(multiplexer_signal.start, multiplexer_signal.byte_order)},{multiplexer_signal.length} {hex_multiplexer_id}h {m_flag}\n'
    for signal in signals:
        message_str += f'Sig="{_get_signal_name(signal)}" {_convert_start(signal.start, signal.byte_order)}\n'
    return message_str

def _dump_messages(database: InternalDatabase) -> str:
    send_messages = []
    receive_messages = []
    send_receive_messages = []
    message_name: str
    messages_with_name: Iterator[Message]
    for message_name, messages_with_name in groupby(sorted(database.messages, key=lambda m: m.name), key=lambda m: m.name):
        message_dumps = []
        # Cantools represents SYM CAN ID range with multiple messages - need to dedup multiple cantools messages
        # into a single message with a CAN ID range
        messages_with_name_list = list(messages_with_name)
        num_messages_with_name = len(messages_with_name_list)
        if num_messages_with_name == 1:
            message = messages_with_name_list[0]
            min_frame_id = message.frame_id
            max_frame_id = None
        else:
            message = min(messages_with_name_list, key=lambda m: m.frame_id)
            min_frame_id = message.frame_id
            max_frame_id = max(messages_with_name_list, key=lambda m: m.frame_id).frame_id
            frame_id_range = max_frame_id - min_frame_id + 1
            if frame_id_range != num_messages_with_name:
                raise ValueError(f'Expected {frame_id_range} messages with name {message_name} - given {num_messages_with_name}')

        if message.is_multiplexed():
            non_multiplexed_signals = []
            # Store all non-multiplexed signals first
            for signal_tree_signal in message.signal_tree:
                if not isinstance(signal_tree_signal, collections.abc.Mapping):
                    non_multiplexed_signals.append(signal_tree_signal)
            
            for signal_tree_signal in message.signal_tree:
                if isinstance(signal_tree_signal, collections.abc.Mapping):
                    signal_name, multiplexed_signals = list(signal_tree_signal.items())[0]
                    is_first_message = True
                    for multiplexer_id, signals_for_multiplexer in multiplexed_signals.items():
                        message_dumps.append(_dump_message(message, [message.get_signal_by_name(s) for s in signals_for_multiplexer] + non_multiplexed_signals,
                                                           min_frame_id if is_first_message else None, max_frame_id, multiplexer_id, message.get_signal_by_name(signal_name)))
                        is_first_message = False
        else:
            message_dumps.append(_dump_message(message, message.signals, min_frame_id, max_frame_id))
        
        if message.senders == [SEND_MESSAGE_SENDER]:
            send_messages.extend(message_dumps)
        elif message.senders == [RECEIVE_MESSAGE_SENDER]:
            receive_messages.extend(message_dumps)
        else:
            send_receive_messages.extend(message_dumps)
    
    messages_dump = ''
    if send_messages:
        messages_dump += '{SEND}\n' + '\n'.join(send_messages) + '\n'
    if receive_messages:
        messages_dump += '{RECEIVE}\n' + '\n'.join(receive_messages) + '\n'
    if send_receive_messages:
        messages_dump += '{SENDRECEIVE}\n' + '\n'.join(send_receive_messages) + '\n'
    return messages_dump

def dump_string(database: InternalDatabase, *, sort_signals:type_sort_signals=SORT_SIGNALS_DEFAULT) -> str:
    """Format given database in SYM file format.

    """
    if sort_signals == SORT_SIGNALS_DEFAULT:
        sort_signals = sort_signals_by_start_bit
        
    sym_str = 'FormatVersion=6.0 // Do not edit this line!\n'
    sym_str += 'Title="SYM Database"\n\n'

    sym_str += _dump_choices(database) + '\n\n'
    sym_str += _dump_signals(database, sort_signals) + '\n\n'
    sym_str += _dump_messages(database)

    return sym_str

def load_string(string:str, strict:bool=True, sort_signals:type_sort_signals=sort_signals_by_start_bit) -> InternalDatabase:
    """Parse given string.

    """

    if not re.search('^FormatVersion=6.0', string, re.MULTILINE):
        raise ParseError('Only SYM version 6.0 is supported.')

    tokens = Parser60().parse(string)

    version = _load_version(tokens)
    enums = _load_enums(tokens)
    signals = _load_signals(tokens, enums)
    messages = _load_messages(tokens, signals, enums, strict, sort_signals)

    return InternalDatabase(messages,
                            [],
                            [],
                            version)
