# -*- coding: utf-8 -*-
# Copyright (C) 2016, Almar Klein

"""
Reading and saving Zoof Object Notation files. ZON is like JSON, but a
more Pythonic format. It's just about 500 lines of code.

This format is a spin-off from the SSDF format, it is fully compatible,
except that ZON does not support numpy arrays.
"""

import re
import sys
import time


# From six.py
if sys.version_info[0] >= 3:
    string_types = (str,)
    integer_types = (int,)
else:
    string_types = (basestring,)  # noqa
    integer_types = (int, long)  # noqa
float_types = (float,)


## Dict class

try:  # pragma: no cover
    from collections import OrderedDict as _dict
except ImportError:
    _dict = dict


def isidentifier(s):
    # http://stackoverflow.com/questions/2544972/
    if not isinstance(s, str):
        return False
    return re.match(r"^\w+$", s, re.UNICODE) and re.match(r"^[0-9]", s) is None


class Dict(_dict):
    """A dict in which the items can be get/set as attributes."""

    __reserved_names__ = dir(_dict())  # Also from OrderedDict
    __pure_names__ = dir(dict())

    __slots__ = []

    def __repr__(self):
        identifier_items = []
        nonidentifier_items = []
        for key, val in self.items():
            if isidentifier(key):
                identifier_items.append("%s=%r" % (key, val))
            else:
                nonidentifier_items.append("(%r, %r)" % (key, val))
        if nonidentifier_items:
            return "Dict([%s], %s)" % (
                ", ".join(nonidentifier_items),
                ", ".join(identifier_items),
            )
        else:
            return "Dict(%s)" % (", ".join(identifier_items))

    def __getattribute__(self, key):
        try:
            return object.__getattribute__(self, key)
        except AttributeError:
            if key in self:
                return self[key]
            else:
                raise

    def __setattr__(self, key, val):
        if key in Dict.__reserved_names__:
            # Either let OrderedDict do its work, or disallow
            if key not in Dict.__pure_names__:
                return _dict.__setattr__(self, key, val)
            else:
                raise AttributeError(
                    "Reserved name, this key can only "
                    + "be set via ``d[%r] = X``" % key
                )
        else:
            # if isinstance(val, dict): val = Dict(val) -> no, makes a copy!
            self[key] = val

    def __dir__(self):
        names = [k for k in self.keys() if isidentifier(k)]
        return Dict.__reserved_names__ + names


# SSDF compatibility
Struct = Dict
Struct.__is_ssdf_struct__ = True


## Public functions


def isstruct(ob):  # SSDF compatibility
    """isstruct(ob)

    Returns whether the given object is an SSDF struct.
    """
    if hasattr(ob, "__is_ssdf_struct__"):
        return bool(ob.__is_ssdf_struct__)
    else:
        return False


def new():
    """new()

    Create a new Dict object. The same as "Dict()".
    """
    return Dict()


def clear(d):  # SSDF compatibility
    """clear(d)

    Clear all elements of the given Dict object.
    """
    d.clear()


def copy(object):
    """copy(objec)

    Return a deep copy the given object. The object and its children
    should be dict-compatible data types. Note that dicts are converted
    to Dict and tuples to lists.
    """
    if isstruct(object) or isinstance(object, dict):
        newObject = Dict()
        for key in object:
            val = object[key]
            newObject[key] = copy(val)
        return newObject
    elif isinstance(object, (tuple, list)):
        return [copy(ob) for ob in object]
    else:
        return object  # immutable


def count(object, cache=None):
    """count(object):

    Count the number of elements in the given object. An element is
    defined as one of the 6 datatypes supported by ZON (dict,
    tuple/list, string, int, float, None).
    """
    cache = cache or []
    if isstruct(object) or isinstance(object, (dict, list)):
        if id(object) in cache:
            raise RuntimeError("recursion!")
        cache.append(id(object))
    n = 1
    if isstruct(object) or isinstance(object, dict):
        for key in object:
            val = object[key]
            n += count(val, cache)
    elif isinstance(object, (tuple, list)):
        for val in object:
            n += count(val, cache)
    return n


def loads(text):
    """loads(text)

    Load a Dict from the given Unicode) string in ZON syntax.
    """
    if not isinstance(text, string_types):
        raise ValueError("zon.loads() expects a string.")
    reader = ReaderWriter()
    return reader.read(text)


def load(file):
    """load(filename)

    Load a Dict from the given file or filename.
    """
    if isinstance(file, string_types):
        file = open(file, "rb")
    text = file.read().decode("utf-8")
    return loads(text)


def saves(d):
    """saves(d)

    Serialize the given dict to a (Unicode) string.
    """
    if not (isstruct(d) or isinstance(d, dict)):
        raise ValueError("ssdf.saves() expects a dict.")
    writer = ReaderWriter()
    text = writer.save(d)
    return text


def save(file, d):
    """save(file, d)

    Serialize the given dict to the given file or filename.
    """
    text = saves(d)
    if isinstance(file, string_types):
        file = open(file, "wb")
    with file:
        file.write(text.encode("utf-8"))


## The core


class ReaderWriter(object):
    def read(self, text):
        indent = 0
        root = Dict()
        container_stack = [(0, root)]
        new_container = None

        for i, line in enumerate(text.splitlines()):
            linenr = i + 1

            # Strip line
            line2 = line.lstrip()

            # Skip comments and empty lines
            if not line2 or line2[0] == "#":
                continue

            # Find the indentation
            prev_indent = indent
            indent = len(line) - len(line2)
            if indent == prev_indent:
                pass
            elif indent < prev_indent:
                while container_stack[-1][0] > indent:
                    container_stack.pop(-1)
                if container_stack[-1][0] != indent:
                    print("ZON: Ignoring wrong dedentation at %i" % linenr)
            elif indent > prev_indent and new_container is not None:
                container_stack.append((indent, new_container))
                new_container = None
            else:
                print("ZON: Ignoring wrong indentation at %i" % linenr)
                indent = prev_indent

            # Split name and data using a regular expression
            m = re.search("^\w+? *?=", line2)
            if m:
                i = m.end(0)
                name = line2[: i - 1].strip()
                data = line2[i:].lstrip()
            else:
                name = None
                data = line2

            # Get value
            value = self.to_object(data, linenr)

            # Store the value
            _indent, current_container = container_stack[-1]
            if isinstance(current_container, dict):
                if name:
                    current_container[name] = value
                else:
                    print("ZON: unnamed item in dict on line %i" % linenr)
            elif isinstance(current_container, list):
                if name:
                    print("ZON: named item in list on line %i" % linenr)
                else:
                    current_container.append(value)
            else:
                raise RuntimeError("Invalid container %r" % current_container)

            # Prepare for next round
            if isinstance(value, (dict, list)):
                new_container = value

        return root

    def save(self, d):
        pyver = "%i.%i.%i" % sys.version_info[:3]
        ct = time.asctime()
        lines = []
        lines.append("# -*- coding: utf-8 -*-")
        lines.append("# This Zoof Object Notation (ZON) file was")
        lines.append("# created from Python %s on %s.\n" % (pyver, ct))
        lines.append("")
        lines.extend(self.from_dict(d, -2)[1:])

        return "\r\n".join(lines)
        # todo: pop toplevel dict

    def from_object(self, name, value, indent):
        # Get object's data
        if value is None:
            data = "Null"
        elif isinstance(value, integer_types):
            data = self.from_int(value)
        elif isinstance(value, float_types):
            data = self.from_float(value)
        elif isinstance(value, bool):
            data = self.from_int(int(value))
        elif isinstance(value, string_types):
            data = self.from_unicode(value)
        elif isinstance(value, dict):
            data = self.from_dict(value, indent)
        elif isinstance(value, (list, tuple)):
            data = self.from_list(value, indent)
        else:
            # We do not know
            data = "Null"
            tmp = repr(value)
            if len(tmp) > 64:
                tmp = tmp[:64] + "..."
            if name is not None:
                print("ZON: %s is unknown object: %s" % (name, tmp))
            else:
                print("ZON: unknown object: %s" % tmp)

        # Finish line (or first line)
        if isinstance(data, string_types):
            data = [data]
        if name:
            data[0] = "%s%s = %s" % (" " * indent, name, data[0])
        else:
            data[0] = "%s%s" % (" " * indent, data[0])

        return data

    def to_object(self, data, linenr):
        data = data.lstrip()

        # Determine what type of object we're dealing with by reading
        # like a human.
        if not data:
            print("ZON: no value specified at line %i." % linenr)
        elif data[0] in "-.0123456789":
            return self.to_int_or_float(data, linenr)
        elif data[0] == "'":
            return self.to_unicode(data, linenr)
        elif data.startswith("dict:"):
            return self.to_dict(data, linenr)
        elif data.startswith("list:") or data[0] == "[":
            return self.to_list(data, linenr)
        elif data.startswith("Null") or data.startswith("None"):
            return None
        else:
            print("ZON: invalid type on line %i." % linenr)
            return None

    def to_int_or_float(self, data, linenr):
        line = data.partition("#")[0]
        try:
            return int(line)
        except ValueError:
            try:
                return float(line)
            except ValueError:
                print("ZON: could not parse number on line %i." % linenr)
                return None

    def from_int(self, value):
        return repr(int(value)).rstrip("L")

    def from_float(self, value):
        # Use general specifier with a very high precision.
        # Any spurious zeros are automatically removed. The precision
        # should be sufficient such that any numbers saved and loaded
        # back will have the exact same value again.
        # see e.g. http://bugs.python.org/issue1580
        return repr(float(value))  # '%0.17g' % value

    def from_unicode(self, value):
        value = value.replace("\\", "\\\\")
        value = value.replace("\n", "\\n")
        value = value.replace("\r", "\\r")
        value = value.replace("\x0b", "\\x0b").replace("\x0c", "\\x0c")
        value = value.replace("'", "\\'")
        return "'" + value + "'"

    def to_unicode(self, data, linenr):
        # Encode double slashes
        line = data.replace("\\\\", "0x07")  # temp

        # Find string using a regular expression
        m = re.search("'.*?[^\\\\]'|''", line)
        if not m:
            print("ZON: string not ended correctly on line %i." % linenr)
            return None  # return not-a-string
        else:
            line = m.group(0)[1:-1]

        # Decode stuff
        line = line.replace("\\n", "\n")
        line = line.replace("\\r", "\r")
        line = line.replace("\\x0b", "\x0b").replace("\\x0c", "\x0c")
        line = line.replace("\\'", "'")
        line = line.replace("0x07", "\\")
        return line

    def from_dict(self, value, indent):
        lines = ["dict:"]
        # Process children
        for key, val in value.items():
            # Skip all the builtin stuff
            if key.startswith("__"):
                continue
            # Skip methods, or anything else we can call
            if hasattr(val, "__call__"):
                continue  # Note: py3.x does not have function callable
            # Add!
            lines.extend(self.from_object(key, val, indent + 2))
        return lines

    def to_dict(self, data, linenr):
        return Dict()

    def from_list(self, value, indent):
        # Collect subdata and check whether this is a "small list"
        isSmallList = True
        allowedTypes = integer_types + float_types + string_types
        subItems = []
        for element in value:
            if not isinstance(element, allowedTypes):
                isSmallList = False
            subdata = self.from_object(None, element, 0)  # No indent
            subItems.extend(subdata)
        isSmallList = isSmallList and len(subItems) < 256

        # Return data
        if isSmallList:
            return "[%s]" % (", ".join(subItems))
        else:
            data = ["list:"]
            ind = " " * (indent + 2)
            for item in subItems:
                data.append(ind + item)
            return data

    def to_list(self, data, linenr):
        value = []
        if data[0] == "l":  # list:
            return list()
        else:
            i0 = 1
            pieces = []
            inString = False
            escapeThis = False
            line = data
            for i in range(1, len(line)):
                if inString:
                    # Detect how to get out
                    if escapeThis:
                        escapeThis = False
                        continue
                    elif line[i] == "\\":
                        escapeThis = True
                    elif line[i] == "'":
                        inString = False
                else:
                    # Detect going in a string, break, or end
                    if line[i] == "'":
                        inString = True
                    elif line[i] == ",":
                        pieces.append(line[i0:i])
                        i0 = i + 1
                    elif line[i] == "]":
                        piece = line[i0:i]
                        if piece.strip():  # Do not add if empty
                            pieces.append(piece)
                        break
            else:
                print("ZON: short list not closed right on line %i." % linenr)

            # Cut in pieces and process each piece
            value = []
            for piece in pieces:
                v = self.to_object(piece, linenr)
                value.append(v)
            return value
