# PyADIF-File (c) 2023-2025 by Andreas Schawo is licensed under CC BY-SA 4.0.
# To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/

"""Convert ADIF ADI content to dictionary and vice versa"""

import re
import copy
from warnings import warn
from collections.abc import Iterator

from . import __version_str__, __proj_name__
from .util import get_cur_adif_dt, replace_non_ascii


class TooMuchHeadersException(Exception):
    pass


class TagDefinitionException(Exception):
    pass


class StringNotASCIIException(Exception):
    pass


class IllegalParameterException(Exception):
    pass


class IllegalDataTypeException(Exception):
    pass


class NonASCIIWarning(Warning):
    pass


class InvalidHeaderFieldWarning(Warning):
    pass


class InvalidTypeWarning(Warning):
    pass


class InvalidFieldWarning(Warning):
    pass


REGEX_ASCII = re.compile(r'[ -~\n\r]*')
REGEX_PARAM = re.compile(r'[a-zA-Z][a-zA-Z_0-9]*')


def unpack(data: str, strip_tags: bool = True) -> dict[str, str]:
    """Unpack header or record part to dictionary
    The parameters are converted to uppercase
    :param data: string with multiple ADIF tag and value for a whole record
    :param strip_tags: remove any leading or trailing whitespaces in tag names (default: True)
    :return: dictionary of ADIF tag and value
    :raises TagDefinitionException: if the tag definition is invalid or the length is not an integer"""

    unpacked = {}

    start = -1
    end = -1
    length = 0

    while start < len(data):
        try:
            start = data.index('<', end + 1 + length)
        except ValueError:
            break

        end = data.index('>', start)
        tag = data[start + 1:end]
        dtype = None
        try:
            tag_def = tag.split(':')
            param = tag_def[0].strip() if strip_tags else tag_def[0]
            length = tag_def[1]
            if len(tag_def) == 3:
                dtype = tag_def[2]
        except ValueError:
            raise TagDefinitionException('Wrong tag definition')

        try:
            length = int(length)
        except ValueError:
            raise TagDefinitionException('Wrong length')

        value = data[end + 1:end + 1 + length]
        if param.upper().startswith('USERDEF'):
            if 'USERDEFS' not in unpacked:
                unpacked['USERDEFS'] = []
            unpacked['USERDEFS'].append({'dtype': dtype,
                                         'userdef': value})
        else:
            unpacked[param.upper()] = value

    return unpacked


def loadi(adi: str, skip: int = 0, strip_tags: bool = True) -> Iterator[dict[str, str]]:
    """Turn ADI formated string to header/records as an iterator over dict
    The skip option is useful if you want to watch a file for new records only. This saves processing time.

    :param adi: the ADI data
    :param skip: skip first number of records (does not apply for header)
    :param strip_tags: remove any leading or trailing whitespaces in tag names (default: True)
    :return: an iterator of records (first record is the header even if not available)
    :raises TooMuchHeadersException: if the data contains more than one header
    :raises TagDefinitionException: if the tag definition is invalid or the length is not an integer
    """

    hr_list = re.split(r'<[eE][oO][hH]>', adi)
    if len(hr_list) == 1:  # Header is missing
        yield {}
        record_data = hr_list[0]
    elif len(hr_list) > 2:  # More than one header
        raise TooMuchHeadersException()
    else:  # One header and the records
        yield unpack(hr_list[0], strip_tags)
        record_data = hr_list[1]

    for i, rec in enumerate(re.finditer(r'(.*?)<[eE][oO][rR]>', record_data, re.S)):
        if i >= skip:
            yield unpack(rec.groups()[0], strip_tags)


def loads(adi: str, skip: int = 0, strip_tags: bool = True) -> dict:
    """Turn ADI formated string to dictionary
    The parameters are converted to uppercase

        {
        'HEADER': {},
        'RECORDS': [list of records]
        }

    The skip option is useful if you want to watch a file for new records only. This saves processing time.
    In this case consider to use loadi() directly.

    :param adi: the ADI data
    :param skip: skip first number of records (does not apply for header)
    :param strip_tags: remove any leading or trailing whitespaces in tag names (default: True)
    :return: the ADI as a dict
    :raises TooMuchHeadersException: if the data contains more than one header
    :raises TagDefinitionException: if the tag definition is invalid or the length is not an integer
    """

    doc = {'HEADER': {},
           'RECORDS': []
           }

    first = True
    for rec in loadi(adi, skip, strip_tags):
        if first:
            doc['HEADER'] = rec
            first = False
        else:
            doc['RECORDS'].append(rec)

    return doc


def load(file_name: str, skip: int = 0, encoding=None, strip_tags: bool = True) -> dict:
    """Load ADI formated file to dictionary
    The parameters are converted to uppercase

       {
       'HEADER': {},
       'RECORDS': [list of records]
       }

    The skip option is useful if you want to watch a file for new records only. This saves processing time.
    In this case consider to use loadi() directly.

    :param file_name: the file name where the ADI data is stored
    :param skip: skip first number of records (does not apply for header)
    :param encoding: the file encoding
    :param strip_tags: remove any leading or trailing whitespaces in tag names (default: True)
    :return: the ADI as a dict
    :raises TooMuchHeadersException: if the data contains more than one header
    :raises TagDefinitionException: if the tag definition is invalid or the length is not an integer
    """

    with open(file_name, encoding=encoding) as af:
        data = af.read()

    return loads(data, skip, strip_tags)


def pack(param: str, value: str | int | bool | float, dtype: str = None,
         repl_non_ascii: bool | str | dict[str, str] = True) -> str:
    """Generates ADI tag if value is not empty
    Does not generate tags for *_INTL types as required by specification.

    :param param: the tag parameter (converted to uppercase)
    :param value: the tag value (or tag definition if param is a USERDEF field)
    :param dtype: the optional datatype (mainly used for USERDEFx in header)
    :param repl_non_ascii: replaces non ASCII chars with "_", a given char or according to a mapping and generates a warning instead of raising StringNotASCIIException
    :return: <param:length>value
    :raises StringNotASCIIException: if the value contains non ASCII characters if not repl_non_ascii
    :raises IllegalParameterException: if parameter or data type contains invalid characters
    """

    if not re.fullmatch(REGEX_PARAM, param):
        raise IllegalParameterException(f'Parameter "{param}" contains not allowed characters')

    if not param.upper().endswith('_INTL'):
        if isinstance(value, str) and not re.fullmatch(REGEX_ASCII, value):
            if repl_non_ascii:
                value = replace_non_ascii(value,
                                          replace=repl_non_ascii if isinstance(repl_non_ascii, dict) else None,
                                          default=repl_non_ascii if isinstance(repl_non_ascii, str) else '_')
                warn(f'Replaced non ASCII chars in tag "{param}"', NonASCIIWarning)
            else:
                raise StringNotASCIIException(f'Value "{value}" in parameter "{param}" contains non ASCII characters')

        if dtype:
            if len(dtype) > 1 or dtype not in 'BNDTSEL':
                raise IllegalDataTypeException(f'Datatype "{dtype}" in "{param}"')
            return f'<{param.upper()}:{len(str(value))}:{dtype}>{value}' if str(value) else ''
        else:
            if isinstance(value, bool):
                value = 'Y' if value else 'N'
            elif any([isinstance(value, str), isinstance(value, int), isinstance(value, float)]):
                value = str(value)
            else:
                warn(f'Skipped field "{param}" to to invalid type (allowed: str, bool, int, float)', InvalidTypeWarning)
            return f'<{param.upper()}:{len(str(value))}>{value}' if value else ''
    else:
        warn(f'Field "{param}": *_INTL fields are not allowed in ADI', InvalidFieldWarning)
        return ''


def dumpi(data_dict: dict, comment: str = 'ADIF export by ' + __proj_name__,
          linebreaks: bool = True, spaces: int = 1, repl_non_ascii: bool | str | dict[str, str] = True) -> Iterator[
    str]:
    """Takes a dictionary and converts it to ADI format
    Parameters can be in upper or lower case. The output is upper case. The user must take care
    that parameters are not doubled!
    *_INTL parameters are ignored as they are not allowed in ADI.
    Empty records are skipped.

    If 'HEADER' is present, the comment is added and missing header fields are filled with defaults.
    The header can contain a list of user definitions as USERDEFS. Each user definition is expected as a dictionary
    with datatype as "dtype" and field definition as "userdef" instead of a string value.

    The 'HEADER' can also contain application fields (starting with 'APP_').
    This is not according to the standard (but very useful).

    :param data_dict: the dictionary with header and records
    :param comment: the comment to induce the header
    :param linebreaks: Format output with additional linebreaks for readability
    :param spaces: Number of spaces between fields
    :param repl_non_ascii: replaces non ASCII chars with "_", a given char or according to a mapping and generates a warning instead of raising StringNotASCIIException
    :return: an iterator of chunks of the ADI (header, record 1, ..., record n)
    :raises StringNotASCIIException: if a value in a record contains non ASCII characters
    :raises IllegalParameterException: if a parameter or data type in a record contains invalid characters"""

    data_dict = copy.deepcopy(data_dict)

    field_separator = ' ' * spaces if spaces >= 0 else ' '

    if 'HEADER' in data_dict:
        default = {'ADIF_VER': '3.1.4',
                   'PROGRAMID': __proj_name__,
                   'PROGRAMVERSION': __version_str__,
                   'CREATED_TIMESTAMP': get_cur_adif_dt(),
                   }

        data = comment + ' \n'

        try:
            for p in data_dict['HEADER']:
                if p.upper() in ('ADIF_VER', 'PROGRAMID', 'PROGRAMVERSION', 'CREATED_TIMESTAMP') and \
                        data_dict['HEADER'][p]:
                    data += (pack(p.upper(), data_dict['HEADER'][p], repl_non_ascii=repl_non_ascii)
                             + ('\n' if linebreaks else field_separator))
                    default.pop(p.upper())
                elif p.upper() == 'USERDEFS':
                    for i, u in enumerate(data_dict['HEADER'][p], 1):
                        data += pack(f'USERDEF{i}', u['userdef'], u['dtype'], repl_non_ascii=repl_non_ascii) + (
                            '\n' if linebreaks else field_separator)
                elif p.upper().startswith('APP_') and data_dict['HEADER'][p]:
                    data += (pack(p.upper(), data_dict['HEADER'][p], repl_non_ascii=repl_non_ascii)
                             + ('\n' if linebreaks else field_separator))
                else:
                    warn(f'Skipping invalid header field "{p}"', InvalidHeaderFieldWarning)
            for p in default:
                data += pack(p, default[p], repl_non_ascii=repl_non_ascii) + ('\n' if linebreaks else field_separator)
            data += '<EOH>'
            yield data
        except StringNotASCIIException as exc:
            raise StringNotASCIIException(f'Header: {exc.args[0]}') from None
        except IllegalParameterException as exc:
            raise IllegalParameterException(f'Header: {exc.args[0]}') from None

    if 'RECORDS' in data_dict:
        for r_num, r in enumerate(data_dict['RECORDS']):
            data = ''
            empty = True
            for i, pv in enumerate(zip(r.keys(), r.values()), 1):
                try:
                    tag = pack(pv[0].upper(), pv[1], repl_non_ascii=repl_non_ascii)
                    if tag:
                        empty = False
                        if linebreaks:
                            data += tag + ('\n' if i % 5 == 0 else field_separator)
                        else:
                            data += tag + field_separator
                except StringNotASCIIException as exc:
                    raise StringNotASCIIException(f'Record #{r_num + 1}: {exc.args[0]}') from None
                except IllegalParameterException as exc:
                    raise IllegalParameterException(f'Record #{r_num + 1}: {exc.args[0]}') from None
            if not data.endswith('\n'):
                data += '\n' if linebreaks else ''

            if not empty:
                data += '<EOR>'
                yield data


def dumps(data_dict: dict, comment: str = 'ADIF export by ' + __proj_name__, linebreaks: bool = True,
          repl_non_ascii: bool | str | dict[str, str] = True, **params) -> str:
    """Takes a dictionary and converts it to ADI format
    Parameters can be in upper or lower case. The output is upper case. The user must take care
    that parameters are not doubled!
    *_INTL parameters are ignored as they are not allowed in ADI.
    Empty records are skipped.

    If 'HEADER' is present, the comment is added and missing header fields are filled with defaults.
    The header can contain a list of user definitions as USERDEFS. Each user definition is expected as a dictionary
    with datatype as "dtype" and field definition as "userdef" instead of a string value.

    The 'HEADER' can also contain application fields (starting with 'APP_').
    This is not according to the standard (but very useful).

    :param data_dict: the dictionary with header and records
    :param comment: the comment to induce the header
    :param linebreaks: Format output with additional linebreaks for readability
    :param repl_non_ascii: replaces non ASCII chars with "_", a given char or according to a mapping and generates a warning instead of raising StringNotASCIIException
    :return: the complete ADI as a string
    :raises StringNotASCIIException: if a value in a record contains non ASCII characters
    :raises IllegalParameterException: if a parameter or data type in a record contains invalid characters"""

    line_separator = '\n\n' if linebreaks else '\n'

    return line_separator.join(
        list(dumpi(data_dict, comment, linebreaks=linebreaks, repl_non_ascii=repl_non_ascii, **params)))


def dump(file_name: str, data_dict: dict, comment: str = 'ADIF export by ' + __proj_name__,
         linebreaks: bool = True, encoding='ascii', repl_non_ascii: bool | str | dict[str, str] = True, **params):
    """Takes a dictionary and stores it to filename in ADI format
    Parameters can be in upper or lower case. The output is upper case. The user must take care
    that parameters are not doubled!
    *_INTL parameters are ignored as they are not allowed in ADI.
    Empty records are skipped.

    If 'HEADER' is present the comment is added and missing header fields are filled with defaults.
    The header can contain a list of user definitions as USERDEFS. Each user definition is expected as a dictionary
    with datatype as "dtype" and field definition as "userdef" instead of a string value.

    :param file_name: the filename to store the ADI data to
    :param data_dict: the dictionary with header and records
    :param comment: the comment to induce the header
    :param linebreaks: format output with additional linebreaks for readability
    :param encoding: the file encoding
    :param repl_non_ascii: replaces non ASCII chars with "_", a given char or according to a mapping and generates a warning instead of raising StringNotASCIIException
    :raises StringNotASCIIException: if a value in a record contains non ASCII characters
    :raises IllegalParameterException: if a parameter or data type in a record contains invalid characters"""

    with open(file_name, 'w', encoding=encoding) as af:
        first = True
        for chunk in dumpi(data_dict, comment, linebreaks=linebreaks, repl_non_ascii=repl_non_ascii, **params):
            if first:
                first = False
            else:
                af.write('\n\n' if linebreaks else '\n')
            af.write(chunk)


__all__ = ['load', 'loads', 'loadi', 'dump', 'dumps', 'dumpi',
           'TooMuchHeadersException', 'TagDefinitionException',
           'IllegalDataTypeException', 'IllegalParameterException',
           'StringNotASCIIException']
