"""Access and/or modify INI files

* Compatiable with ConfigParser
* Preserves order of sections & options
* Preserves comments/blank lines/etc
* More conveninet access to data

Example:

    >>> from io import StringIO
    >>> sio = StringIO('''# configure foo-application
    ... [foo]
    ... bar1 = qualia
    ... bar2 = 1977
    ... [foo-ext]
    ... special = 1''')

    >>> cfg = INIConfig(sio)
    >>> print(cfg.foo.bar1)
    qualia
    >>> print(cfg['foo-ext'].special)
    1
    >>> cfg.foo.newopt = 'hi!'
    >>> cfg.baz.enabled = 0

    >>> print(cfg)
    # configure foo-application
    [foo]
    bar1 = qualia
    bar2 = 1977
    newopt = hi!
    [foo-ext]
    special = 1
    <BLANKLINE>
    [baz]
    enabled = 0

"""

# An ini parser that supports ordered sections/options
# Also supports updates, while preserving structure
# Backward-compatible with ConfigParser

import re

from typing import Any, Callable, Dict, TextIO, Iterator, List, Optional, Set, Union
from typing import TYPE_CHECKING

from .configparser import DEFAULTSECT, ParsingError, MissingSectionHeaderError

from . import config

if TYPE_CHECKING:
    from compat import RawConfigParser


class LineType:
    line: Optional[str] = None

    def __init__(self, line: Optional[str] = None) -> None:
        if line is not None:
            self.line = line.strip('\n')

    # Return the original line for unmodified objects
    # Otherwise construct using the current attribute values
    def __str__(self) -> str:
        if self.line is not None:
            return self.line
        else:
            return self.to_string()

    # If an attribute is modified after initialization
    # set line to None since it is no longer accurate.
    def __setattr__(self, name: str, value: object) -> None:
        if hasattr(self, name):
            self.__dict__['line'] = None
        self.__dict__[name] = value

    def to_string(self) -> str:
        # FIXME Raise NotImplementedError instead
        raise Exception('This method must be overridden in derived classes')


class SectionLine(LineType):
    regex = re.compile(r'^\['
                       r'(?P<name>[^]]+)'
                       r'\]\s*'
                       r'((?P<csep>;|#)(?P<comment>.*))?$')

    def __init__(
        self,
        name: str,
        comment: Optional[str] = None,
        comment_separator: Optional[str] = None,
        comment_offset: int = -1,
        line: Optional[str] = None,
    ) -> None:
        super().__init__(line)
        self.name: str = name
        self.comment: Optional[str] = comment
        self.comment_separator: Optional[str] = comment_separator
        self.comment_offset: int = comment_offset

    def to_string(self) -> str:
        out: str = '[' + self.name + ']'
        if self.comment is not None:
            # try to preserve indentation of comments
            out = (out + ' ').ljust(self.comment_offset)
            out = out + self.comment_separator + self.comment
        return out

    @classmethod
    def parse(cls, line: str) -> Optional["SectionLine"]:
        m: Optional[re.Match] = cls.regex.match(line.rstrip())
        if m is None:
            return None
        return cls(m.group('name'), m.group('comment'),
                   m.group('csep'), m.start('csep'),
                   line)


class OptionLine(LineType):
    def __init__(
        self,
        name: str,
        value: object,
        separator: str = ' = ',
        comment: Optional[str] = None,
        comment_separator: Optional[str] = None,
        comment_offset: int = -1,
        line: Optional[str] = None,
    ) -> None:
        super().__init__(line)
        self.name: str = name
        self.value: object = value
        self.separator: str = separator
        self.comment: Optional[str] = comment
        self.comment_separator: Optional[str] = comment_separator
        self.comment_offset: int = comment_offset

    def to_string(self) -> str:
        out: str = '%s%s%s' % (self.name, self.separator, self.value)
        if self.comment is not None:
            # try to preserve indentation of comments
            out = (out + ' ').ljust(self.comment_offset)
            out = out + self.comment_separator + self.comment
        return out

    regex = re.compile(r'^(?P<name>[^:=\s[][^:=]*)'
                       r'(?P<sep>[:=]\s*)'
                       r'(?P<value>.*)$')

    @classmethod
    def parse(cls, line: str) -> Optional["OptionLine"]:
        m: Optional[re.Match] = cls.regex.match(line.rstrip())
        if m is None:
            return None

        name: str = m.group('name').rstrip()
        value: str = m.group('value')
        sep: str = m.group('name')[len(name):] + m.group('sep')

        # comments are not detected in the regex because
        # ensuring total compatibility with ConfigParser
        # requires that:
        #     option = value    ;comment   // value=='value'
        #     option = value;1  ;comment   // value=='value;1  ;comment'
        #
        # Doing this in a regex would be complicated.  I
        # think this is a bug.  The whole issue of how to
        # include ';' in the value needs to be addressed.
        # Also, '#' doesn't mark comments in options...

        coff: int = value.find(';')
        if coff != -1 and value[coff - 1].isspace():
            comment = value[coff + 1:]
            csep = value[coff]
            value = value[:coff].rstrip()
            coff = m.start('value') + coff
        else:
            comment = None
            csep = None
            coff = -1

        return cls(name, value, sep, comment, csep, coff, line)


def change_comment_syntax(comment_chars: str = '%;#', allow_rem: bool = False) -> None:
    comment_chars: str = re.sub(r'([\]\-\^])', r'\\\1', comment_chars)
    regex: str = r'^(?P<csep>[%s]' % comment_chars
    if allow_rem:
        regex += '|[rR][eE][mM]'
    regex += r')(?P<comment>.*)$'
    CommentLine.regex = re.compile(regex)


class CommentLine(LineType):
    regex: re.Pattern = re.compile(r'^(?P<csep>[;#]|[rR][eE][mM])'
                                   r'(?P<comment>.*)$')

    def __init__(self, comment: str = '', separator: str = '#', line: Optional[str] = None) -> None:
        super().__init__(line)
        self.comment: str = comment
        self.separator: str = separator

    def to_string(self) -> str:
        return self.separator + self.comment

    @classmethod
    def parse(cls, line: str) -> Optional["CommentLine"]:
        m: Optional[re.Match] = cls.regex.match(line.rstrip())
        if m is None:
            return None
        return cls(m.group('comment'), m.group('csep'), line)


class EmptyLine(LineType):
    # could make this a singleton
    def to_string(self) -> str:
        return ''

    value = property(lambda self: '')

    @classmethod
    def parse(cls, line: str) -> Optional["EmptyLine"]:
        if line.strip():
            return None
        return cls(line)


class ContinuationLine(LineType):
    regex: re.Pattern = re.compile(r'^\s+(?P<value>.*)$')

    def __init__(self, value: str, value_offset: Optional[int] = None, line: Optional[str] = None) -> None:
        super().__init__(line)
        self.value = value
        if value_offset is None:
            value_offset = 8
        self.value_offset: int = value_offset

    def to_string(self) -> str:
        return ' ' * self.value_offset + self.value

    @classmethod
    def parse(cls, line: str) -> Optional["ContinuationLine"]:
        m: Optional[re.Match] = cls.regex.match(line.rstrip())
        if m is None:
            return None
        return cls(m.group('value'), m.start('value'), line)


class LineContainer:
    def __init__(self, d: Optional[Union[List[LineType], LineType]] = None) -> None:
        self.contents = []
        self.orgvalue: str = None
        if d:
            if isinstance(d, list):
                self.extend(d)
            else:
                self.add(d)

    def add(self, x: LineType) -> None:
        self.contents.append(x)

    def extend(self, x: List[LineType]) -> None:
        for i in x:
            self.add(i)

    def get_name(self) -> str:
        return self.contents[0].name

    def set_name(self, data: str) -> None:
        self.contents[0].name = data

    def get_value(self) -> str:
        if self.orgvalue is not None:
            return self.orgvalue
        elif len(self.contents) == 1:
            return self.contents[0].value
        else:
            return '\n'.join([('%s' % x.value) for x in self.contents
                              if not isinstance(x, CommentLine)])

    def set_value(self, data: object) -> None:
        self.orgvalue = data
        lines: List[str] = ('%s' % data).split('\n')

        # If there is an existing ContinuationLine, use its offset
        value_offset: Optional[int] = None
        for v in self.contents:
            if isinstance(v, ContinuationLine):
                value_offset = v.value_offset
                break

        # Rebuild contents list, preserving initial OptionLine
        self.contents = self.contents[0:1]
        self.contents[0].value = lines[0]
        del lines[0]
        for line in lines:
            if line.strip():
                self.add(ContinuationLine(line, value_offset))
            else:
                self.add(EmptyLine())

    def get_line_number(self) -> Optional[int]:
        return self.contents[0].line_number if self.contents else None

    name = property(get_name, set_name)

    value = property(get_value, set_value)

    line_number = property(get_line_number)

    def __str__(self) -> str:
        s: List[str] = [x.__str__() for x in self.contents]
        return '\n'.join(s)

    def finditer(self, key: str) -> Iterator[Union[SectionLine, OptionLine]]:
        for x in self.contents[::-1]:
            if hasattr(x, 'name') and x.name == key:
                yield x

    def find(self, key: str) -> Union[SectionLine, OptionLine]:
        for x in self.finditer(key):
            return x
        raise KeyError(key)


def _make_xform_property(myattrname: str, srcattrname: Optional[str] = None) -> property:
    private_attrname: str = myattrname + 'value'
    private_srcname: str = myattrname + 'source'
    if srcattrname is None:
        srcattrname = myattrname

    def getfn(self) -> Callable:
        srcobj: Optional[object] = getattr(self, private_srcname)
        if srcobj is not None:
            return getattr(srcobj, srcattrname)
        else:
            return getattr(self, private_attrname)

    def setfn(self, value: Callable) -> None:
        srcobj: Optional[object] = getattr(self, private_srcname)
        if srcobj is not None:
            setattr(srcobj, srcattrname, value)
        else:
            setattr(self, private_attrname, value)

    return property(getfn, setfn)


class INISection(config.ConfigNamespace):
    _lines: List[LineContainer] = None
    _options: Dict[str, object] = None
    _defaults: Optional["INISection"] = None
    _optionxformvalue: "INIConfig" = None
    _optionxformsource: "INIConfig" = None
    _compat_skip_empty_lines: Set[str] = set()

    def __init__(
        self,
        lineobj: LineContainer,
        defaults: Optional["INISection"] = None,
        optionxformvalue: Optional["INIConfig"] = None,
        optionxformsource: Optional["INIConfig"] = None
    ) -> None:
        self._lines = [lineobj]
        self._defaults = defaults
        self._optionxformvalue = optionxformvalue
        self._optionxformsource = optionxformsource
        self._options = {}

    _optionxform = _make_xform_property('_optionxform')

    def _compat_get(self, key: str) -> str:
        # identical to __getitem__ except that _compat_XXX
        # is checked for backward-compatible handling
        if key == '__name__':
            return self._lines[-1].name
        if self._optionxform:
            key = self._optionxform(key)
        try:
            value: str = self._options[key].value
            del_empty: bool = key in self._compat_skip_empty_lines
        except KeyError:
            if self._defaults and key in self._defaults._options:
                value = self._defaults._options[key].value
                del_empty = key in self._defaults._compat_skip_empty_lines
            else:
                raise
        if del_empty:
            value = re.sub('\n+', '\n', value)
        return value

    def _getitem(self, key: str) -> object:
        if key == '__name__':
            return self._lines[-1].name
        if self._optionxform:
            key = self._optionxform(key)
        try:
            return self._options[key].value
        except KeyError:
            if self._defaults and key in self._defaults._options:
                return self._defaults._options[key].value
            else:
                raise

    def __setitem__(self, key: str, value: object) -> None:
        if self._optionxform: xkey = self._optionxform(key)
        else: xkey = key
        if xkey in self._compat_skip_empty_lines:
            self._compat_skip_empty_lines.remove(xkey)
        if xkey not in self._options:
            # create a dummy object - value may have multiple lines
            obj = LineContainer(OptionLine(key, ''))
            self._lines[-1].add(obj)
            self._options[xkey] = obj
        # the set_value() function in LineContainer
        # automatically handles multi-line values
        self._options[xkey].value = value

    def __delitem__(self, key: str) -> None:
        if self._optionxform: key = self._optionxform(key)
        if key in self._compat_skip_empty_lines:
            self._compat_skip_empty_lines.remove(key)
        for l in self._lines:
            remaining = []
            for o in l.contents:
                if isinstance(o, LineContainer):
                    n = o.name
                    if self._optionxform: n = self._optionxform(n)
                    if key != n: remaining.append(o)
                else:
                    remaining.append(o)
            l.contents = remaining
        del self._options[key]

    def __iter__(self) -> Iterator[str]:
        d = set()
        for l in self._lines:
            for x in l.contents:
                if isinstance(x, LineContainer):
                    if self._optionxform:
                        ans = self._optionxform(x.name)
                    else:
                        ans = x.name
                    if ans not in d:
                        yield ans
                        d.add(ans)
        if self._defaults:
            for x in self._defaults:
                if x not in d:
                    yield x
                    d.add(x)

    def _new_namespace(self, name):
        raise Exception('No sub-sections allowed', name)


def make_comment(line: str) -> CommentLine:
    return CommentLine(line.rstrip('\n'))


def readline_iterator(f: TextIO) -> Iterator[str]:
    """Iterate over a file by only using the file object's readline method."""
    have_newline: bool = False
    while True:
        line: Optional[str] = f.readline()

        if not line:
            if have_newline:
                yield ""
            return

        if line.endswith('\n'):
            have_newline = True
        else:
            have_newline = False

        yield line


def lower(x: str) -> str:
    return x.lower()


class INIConfig(config.ConfigNamespace):
    _data: LineContainer = None
    _sections: Dict[str, object] = None
    _defaults: INISection = None
    _optionxformvalue: Callable = None
    _optionxformsource: Optional["INIConfig"] = None
    _sectionxformvalue: Optional["INIConfig"] = None
    _sectionxformsource: Optional["INIConfig"] = None
    _parse_exc = None
    _bom = False

    def __init__(
        self,
        fp: TextIO = None,
        defaults: Dict[str, object] = None,
        parse_exc: bool = True,
        optionxformvalue: Callable = lower,
        optionxformsource: Optional[Union["INIConfig", "RawConfigParser"]] = None,
        sectionxformvalue: Optional["INIConfig"] = None,
        sectionxformsource: Optional["INIConfig"] = None,
    ) -> None:
        self._data = LineContainer()
        self._parse_exc = parse_exc
        self._optionxformvalue = optionxformvalue
        self._optionxformsource = optionxformsource
        self._sectionxformvalue = sectionxformvalue
        self._sectionxformsource = sectionxformsource
        self._sections: Dict[str, INISection] = {}
        if defaults is None:
            defaults = {}
        self._defaults = INISection(LineContainer(), optionxformsource=self)
        for name, value in defaults.items():
            self._defaults[name] = value
        if fp is not None:
            self._readfp(fp)

    _optionxform = _make_xform_property('_optionxform', 'optionxform')
    _sectionxform = _make_xform_property('_sectionxform', 'optionxform')

    def _getitem(self, key: str) -> INISection:
        if key == DEFAULTSECT:
            return self._defaults
        if self._sectionxform:
            key = self._sectionxform(key)
        return self._sections[key]

    def __setitem__(self, key: str, value: object):
        raise Exception('Values must be inside sections', key, value)

    def __delitem__(self, key: str) -> None:
        if self._sectionxform:
            key = self._sectionxform(key)
        for line in self._sections[key]._lines:
            self._data.contents.remove(line)
        del self._sections[key]

    def __iter__(self) -> Iterator[str]:
        d = set()
        d.add(DEFAULTSECT)
        for x in self._data.contents:
            if isinstance(x, LineContainer):
                if x.name not in d:
                    yield x.name
                    d.add(x.name)

    def _new_namespace(self, name: str) -> INISection:
        if self._data.contents:
            self._data.add(EmptyLine())
        obj = LineContainer(SectionLine(name))
        self._data.add(obj)
        if self._sectionxform:
            name = self._sectionxform(name)
        if name in self._sections:
            ns = self._sections[name]
            ns._lines.append(obj)
        else:
            ns = INISection(obj, defaults=self._defaults,
                            optionxformsource=self)
            self._sections[name] = ns
        return ns

    def __str__(self) -> str:
        if self._bom:
            fmt = u'\ufeff%s'
        else:
            fmt = '%s'
        return fmt % self._data.__str__()

    _line_types = [EmptyLine, CommentLine,
                   SectionLine, OptionLine,
                   ContinuationLine]

    def _parse(self, line: str) -> Any:
        for linetype in self._line_types:
            lineobj = linetype.parse(line)
            if lineobj:
                return lineobj
        else:
            # can't parse line
            return None

    def _readfp(self, fp: TextIO) -> None:
        cur_section = None
        cur_option = None
        cur_section_name = None
        cur_option_name = None
        pending_lines = []
        pending_empty_lines = False
        try:
            fname = fp.name
        except AttributeError:
            fname = '<???>'
        line_count = 0
        exc = None
        line = None

        for line in readline_iterator(fp):
            # Check for BOM on first line
            if line_count == 0 and isinstance(line, str):
                if line[0] == u'\ufeff':
                    line = line[1:]
                    self._bom = True

            line_obj = self._parse(line)
            line_count += 1
            if not cur_section and not isinstance(line_obj, (CommentLine, EmptyLine, SectionLine)):
                if self._parse_exc:
                    raise MissingSectionHeaderError(fname, line_count, line)
                else:
                    line_obj = make_comment(line)

            if line_obj is None:
                if self._parse_exc:
                    if exc is None:
                        exc = ParsingError(fname)
                    exc.append(line_count, line)
                line_obj = make_comment(line)

            if isinstance(line_obj, ContinuationLine):
                if cur_option:
                    if pending_lines:
                        cur_option.extend(pending_lines)
                        pending_lines = []
                        if pending_empty_lines:
                            optobj._compat_skip_empty_lines.add(cur_option_name)
                            pending_empty_lines = False
                    cur_option.add(line_obj)
                else:
                    # illegal continuation line - convert to comment
                    if self._parse_exc:
                        if exc is None:
                            exc = ParsingError(fname)
                        exc.append(line_count, line)
                    line_obj = make_comment(line)

            if isinstance(line_obj, OptionLine):
                if pending_lines:
                    cur_section.extend(pending_lines)
                    pending_lines = []
                    pending_empty_lines = False
                cur_option = LineContainer(line_obj)
                cur_section.add(cur_option)
                if self._optionxform:
                    cur_option_name = self._optionxform(cur_option.name)
                else:
                    cur_option_name = cur_option.name
                if cur_section_name == DEFAULTSECT:
                    optobj = self._defaults
                else:
                    optobj = self._sections[cur_section_name]
                optobj._options[cur_option_name] = cur_option

            if isinstance(line_obj, SectionLine):
                self._data.extend(pending_lines)
                pending_lines = []
                pending_empty_lines = False
                cur_section = LineContainer(line_obj)
                self._data.add(cur_section)
                cur_option = None
                cur_option_name = None
                if cur_section.name == DEFAULTSECT:
                    self._defaults._lines.append(cur_section)
                    cur_section_name = DEFAULTSECT
                else:
                    if self._sectionxform:
                        cur_section_name = self._sectionxform(cur_section.name)
                    else:
                        cur_section_name = cur_section.name
                    if cur_section_name not in self._sections:
                        self._sections[cur_section_name] = \
                                INISection(cur_section, defaults=self._defaults,
                                           optionxformsource=self)
                    else:
                        self._sections[cur_section_name]._lines.append(cur_section)

            if isinstance(line_obj, (CommentLine, EmptyLine)):
                pending_lines.append(line_obj)
                if isinstance(line_obj, EmptyLine):
                    pending_empty_lines = True

            if line_obj:
                line_obj.line_number = line_count

        self._data.extend(pending_lines)
        if line and line[-1] == '\n':
            self._data.add(EmptyLine())

        if exc:
            raise exc
