import json
import math
from collections import OrderedDict
import os
import sunspec2.mdef as mdef
import sunspec2.smdx as smdx
import sunspec2.mb as mb
import time


class ModelError(Exception):
    pass


ACCESS_REGION_REGS = 123

this_dir, this_filename = os.path.split(__file__)
models_dir = os.path.join(this_dir, 'models')

model_defs_path = ['.', models_dir]
model_path_options = ['.', 'json', 'smdx']
model_defs_cache = {}


def clear_model_defs_cache():
    global model_defs_cache
    model_defs_cache = {}


def set_model_defs_path(path_list):
    if not isinstance(path_list, list):
        raise mdef.ModelDefinitionError('Invalid path list type, path list is not a list')
    global model_defs_path
    model_defs_path = path_list
    clear_model_defs_cache()


def get_model_defs_path():
    return model_defs_path


def get_model_info(model_id):
    try:
        glen = 0
        model_def = get_model_def(model_id)
        gdef = model_def.get(mdef.GROUP)
        # check if groups have a count point
        has_group_count = check_group_count(gdef)
        # if group has count point, compute the length of top-level points
        if has_group_count:
            points = gdef.get(mdef.POINTS)
            if points:
                for pdef in points:
                    info = mb.point_type_info.get(pdef[mdef.TYPE])
                    plen = pdef.get(mdef.SIZE, None)
                    if plen is not None:
                        glen += info.len
    except:
        raise

    return (model_def, has_group_count, glen)


def check_group_count(gdef):
    has_group_count = (gdef.get(mdef.COUNT) is not None)
    if not has_group_count:
        groups = gdef.get(mdef.GROUPS)
        if groups:
            for g in groups:
                has_group_count = check_group_count(g)
                if has_group_count:
                    break
    return has_group_count


def get_model_def(model_id, mapping=True):
    try:
        model_id = int(model_id)
    except:
        raise mdef.ModelDefinitionError('Invalid model id: %s' % model_id)

    global model_defs_cache
    if (model_id, mapping) in model_defs_cache:
        return model_defs_cache[(model_id, mapping)].copy()

    model_def_file_json = mdef.to_json_filename(model_id)
    model_def_file_smdx = smdx.to_smdx_filename(model_id)
    model_def = None
    for path in model_defs_path:
        # look in directory, then json/, then smdx/
        for path_option in model_path_options:
            try:
                model_def = mdef.from_json_file(os.path.join(path, path_option, model_def_file_json))
            except FileNotFoundError:
                pass
            except Exception as e:
                raise mdef.ModelDefinitionError('Error loading model definition for model %s: %s' %
                                                 (model_id, str(e)))

            if model_def is None:
                try:
                    model_def = smdx.from_smdx_file(os.path.join(path, path_option, model_def_file_smdx))
                except FileNotFoundError:
                    pass
                except Exception as e:
                    raise mdef.ModelDefinitionError('Error loading model definition for model %s: %s' %
                                                     (model_id, str(e)))

            if model_def is not None:
                if mapping:
                    add_mappings(model_def[mdef.GROUP])
                model_defs_cache[(model_id, mapping)] = model_def.copy()
                return model_def
    raise mdef.ModelDefinitionError('Model definition not found for model %s' % model_id)


# add id mapping for points and groups for more efficient lookup by id
def add_mappings(group_def):
    point_defs = {}
    group_defs = {}

    points = group_def.get(mdef.POINTS, None)
    if points:
        for p in group_def[mdef.POINTS]:
            point_defs[p[mdef.NAME]] = p

    groups = group_def.get(mdef.GROUPS, None)
    if groups:
        for g in group_def[mdef.GROUPS]:
            group_defs[g[mdef.NAME]] = g
            add_mappings(g)

    group_def['point_defs'] = point_defs
    group_def['group_defs'] = group_defs


class Point(object):
    def __init__(self, pdef=None, model=None, group=None, model_offset=0, data=None, data_offset=0):
        self.model = model          # model object containing the point
        self.group = group
        self.pdef = pdef            # point definition
        self.len = 0                # mb register len of point
        self.info = None            # point def info
        self.offset = model_offset  # mb register offset from beginning of the model
        self._value = None          # value
        self.dirty = False          # value has been changed without being written
        self.sf = None              # scale factor point name
        self.sf_value = None        # value of scale factor
        self.sf_required = False    # point has a scale factor
        self.detail = None          # detailed description
        self.standards = []         # list of standards requiring this point's implementation
        self.read_func = None       # function to be called on read
        self.read_func_arg = None   # the argument passed to the read_func
        self.write_func = None      # function to be called on write
        self.write_func_arg = None  # the argument passed to the write_func
        self.static = None

        if pdef:
            self.sf_required = (pdef.get(mdef.SF) is not None)
            if self.sf_required:
                sf = self.pdef.get(mdef.SF)
                try:
                    self.sf_value = int(sf)
                except ValueError:
                    self.sf = sf

            self.info = mb.point_type_info.get(pdef[mdef.TYPE])
            plen = pdef.get(mdef.SIZE, None)
            self.len = self.info.len
            if plen is not None:
                self.len = int(plen)

            if data is not None:
                self._set_data(data=data, offset=data_offset)

            static = pdef.get('static', None)
            if static and static == 'S':
                self.static = True

            standards = pdef.get('standards', None)
            if standards:
                if not isinstance(standards, list):
                    standards = [standards]
                self.standards = standards

            self.detail = pdef.get('detail', None)

    def __str__(self):
        return self.disp()

    def disp(self, indent=None):
        if indent is None:
            indent = ''
        return '%s%s:  %s\n' % (indent, self.pdef[mdef.NAME], self.value)

    def _set_data(self, data=None, offset=0):
        if isinstance(data, (bytes, bytearray)):
            byte_offset = offset * 2
            if byte_offset < len(data):
                self.set_mb(data=data[byte_offset:], dirty=False)
        elif isinstance(data, dict):
            value = data.get(self.pdef[mdef.NAME])
            if value is not None:
                self.set_value(data=value)

    def resolve_sf(self):
        pass

    @property
    def value(self):
        return self.get_value()

    @value.setter
    def value(self, v):
        self.set_value(v, dirty=True)

    @property
    def cvalue(self):
        return self.get_value(computed=True)

    @cvalue.setter
    def cvalue(self, v):
        self.set_value(v, computed=True, dirty=True)

    def get_value(self, computed=False):
        # call read function, if set
        if self.read_func:
            self.read_func(self.model, self.read_func_arg)

        v = self._value
        if computed and v is not None:
            if self.sf_required:
                if self.sf_value is None or not self.static:
                    if self.sf:
                        sf = self.group.points.get(self.sf)
                        if sf is None:
                            sf = self.model.points.get(self.sf)
                        if sf is not None:
                            self.sf_value = sf.value
                        else:
                            raise ModelError('Scale factor %s for point %s not found' % (self.sf, self.pdef['name']))
            if self.sf_value:
                sfv = self.sf_value
                if sfv:
                    v = round(v * math.pow(10, sfv), -1 * sfv)

        return v

    def set_value(self, data=None, computed=False, dirty=None):
        v = data
        if dirty is not None:
            self.dirty = dirty
        if computed:
            if self.sf_required:
                if self.sf_value is None or not self.static:
                    if self.sf:
                        sf = self.group.points.get(self.sf)
                        if sf is None:
                            sf = self.model.points.get(self.sf)
                        if sf is not None:
                            self.sf_value = sf.value
                            if sf.value is not None:
                                self.sf_value = sf.value
                            else:
                                raise ModelError('SF field %s value not initialized for point %s' %
                                                 (self.sf, self.pdef['name']))
                        else:
                            raise ModelError('Scale factor %s for point %s not found' % (self.sf, self.pdef['name']))
            if self.sf_value is not None:
                self._value = round(round(float(v), abs(self.sf_value)) / math.pow(10, self.sf_value))
            else:
                self._value = v
        else:
            self._value = v

        # call write function, if set
        # should be used to set indication for subsequent processing rather than do detailed processing
        if self.write_func:
            self.write_func(self.model, self.write_func_arg)

    def set_read_func(self, func, arg=None):
        self.read_func = func
        self.read_func_arg = arg

    def set_write_func(self, func, arg=None):
        self.write_func = func
        self.write_func_arg = arg

    def get_mb(self, computed=False):
        v = self._value
        data = err = None
        if computed and v is not None:
            if self.sf_required:
                if self.sf_value is None or not self.static:
                    if self.sf:
                        sf = self.group.points.get(self.sf)
                        if sf is None:
                            sf = self.model.points.get(self.sf)
                        if sf is not None:
                            self.sf_value = sf.value
                        else:
                            raise ModelError('Scale factor %s for point %s not found' % (self.sf, self.pdef['name']))
            if self.sf_value:
                sfv = self.sf_value
                if sfv:
                    v = int(v * math.pow(10, sfv))
                try:
                    data = self.info.to_data(v, (int(self.len) * 2))
                except Exception as e:
                    err = 'Error getting point value %s %s: %s' % (self.pdef[mdef.NAME], v, e)
                if err:
                    raise ModelError(err)
        elif v is None:
            data = mb.create_unimpl_value(self.pdef[mdef.TYPE], len=(int(self.len) * 2))

        if data is None:
            try:
                data = self.info.to_data(v, (int(self.len) * 2))
            except Exception as e:
                err = 'Error getting point value %s %s: %s' % (self.pdef[mdef.NAME], v, e)
            if err:
                raise ModelError(err)
        return data

    def set_mb(self, data=None, computed=False, dirty=None):
        mb_len = self.len
        try:
            # if not enough data, do not set but consume the data
            if len(data) < mb_len * 2:
                return len(data)
            self.set_value(self.info.data_to(data[:mb_len * 2]), computed=computed, dirty=dirty)
            if not self.info.is_impl(self.value):
                self.set_value(None)
                self.sf_value = None
        except Exception as e:
            self.model.add_error('Error setting value for %s: %s' % (self.pdef[mdef.NAME], str(e)))
        return mb_len

    def is_impl(self):
        impl = False
        v = self.value
        if v is not None:
            impl = self.info.is_impl(self.value)
        return impl

    def get_text(self, index=None, parent_index=None):
        txt = ''
        name = self.pdef['name']
        val = self.value
        units = self.pdef.get('units')
        if index:
            if parent_index:
                group_index = f'{parent_index:02}'
                group_index += ':' + f'{index:02}'
                txt += '%6s' % (group_index + ':')
            else:
                group_index = f'{index:02}'
                txt += '%6s' % (group_index + ':')
        else:
            txt += ' ' * 6

        txt += name
        length = 55 - len(name)
        txt += '%*s' % (length, str(val))
        if units:
            txt += ' ' + units + '\n'
        else:
            txt += '\n'

        return txt


class Group(object):
    def __init__(self, gdef=None, model=None, model_offset=0, group_len=0, data=None, data_offset=0, group_class=None,
                 point_class=None, index=None):
        self.gdef = gdef
        self.model = model
        self.gname = None
        self.offset = model_offset
        self.len = group_len
        self.points = OrderedDict()
        self.groups = OrderedDict()
        self.points_len = 0
        self.group_class = group_class
        self.index = index
        self.access_regions = []

        if group_class is None:
            self.group_class = self.__class__
        if point_class is None:
            point_class = Point

        if gdef is not None:
            self.gname = gdef[mdef.NAME]
            self.gdef = gdef

            # initialize points and point values, if present
            points = self.gdef.get(mdef.POINTS)
            if points:
                for pdef in points:
                    # allow legacy model 1 to have an alternate length of 65 registers
                    if self.len != 65 or model.model_id != 1 or pdef[mdef.NAME] != 'Pad':
                        p = point_class(pdef, model=self.model, group=self, model_offset=model_offset, data=data,
                                        data_offset=data_offset)
                        self.points_len += p.len
                        model_offset += p.len
                        data_offset += p.len
                        self.points[pdef[mdef.NAME]] = p
            # initialize groups
            groups = self.gdef.get(mdef.GROUPS)
            if groups:
                for gdef in groups:
                    gdata = self._group_data(data=data, name=gdef[mdef.NAME])
                    if gdef.get(mdef.COUNT) is not None:
                        g = self._init_repeating_group(gdef=gdef, model_offset=model_offset, data=gdata,
                                                       data_offset=data_offset)
                        group_count = len(g)
                        if group_count:
                            self.groups[gdef[mdef.NAME]] = g
                            glen = g[0].len * group_count
                            model_offset += glen
                            data_offset += glen
                    else:
                        g = self.group_class(gdef, model=self.model, model_offset=model_offset, data=gdata,
                                        data_offset=data_offset)
                        self.groups[gdef[mdef.NAME]] = g
                        model_offset += g.len
                        data_offset += g.len
            mlen = model_offset - self.offset
            if self.len:
                if self.len + 2 != mlen:
                    self.model.add_error('Model length %s not equal to calculated model length %s for model %s' %
                                         (self.len + 2, mlen, self.model.model_id))
            self.len = mlen

        # check if group fits in access region
        if self.len > ACCESS_REGION_REGS:
            index, count = self._init_access(self.access_regions, index=0, count=0)
            # add last region if pending
            if count > 0:
                self.access_regions.append((index, count))

        len_point = self.points.get('L')
        if len_point:
            len_point.set_value(self.len - 2)

        id_point = self.points.get('ID')
        if id_point:
            id_val = id_point.pdef.get('value')
            if id_val:
                id_point.set_value(id_point.pdef['value'])

    def _init_access(self, access_regions, index, count):
        # add points
        if self.points:
            for p, point in self.points.items():
                if count + point.len > ACCESS_REGION_REGS:
                    access_regions.append((index, count))
                    index += count
                    count = 0
                count += point.len
        # add groups
        if self.groups:
            for g, groups in self.groups.items():
                if isinstance(groups, list) and len(groups) > 0:
                    for group in groups:
                        index, count = group._init_access(access_regions, index, count)
                else:
                    index, count = groups._init_access(access_regions, index, count)

        return index, count

    def __getattr__(self, attr):
        v = self.points.get(attr)
        if v is None:
            v = self.groups.get(attr)
        if v is None:
            raise AttributeError("%s object has no attribute %s" % (self.group_class.__name__, attr))
        return v

    def __str__(self):
        return self.disp()

    def disp(self, indent=None):
        if indent is None:
            indent = ''
        if self.index is not None:
            index = '(%s)' % self.index
        else:
            index = ''
        s = '%s%s%s:\n' % (indent, self.gdef[mdef.NAME], index)

        indent += '  '
        for k, p in self.points.items():
            s += p.disp(indent)

        for k, g in self.groups.items():
            if isinstance(g, list):
                for i in range(len(g)):
                    s += g[i].disp(indent=indent)
            else:
                s += g.disp(indent)

        return s

    def _group_data(self, data=None, name=None, index=None):
        if isinstance(data, dict):
            data = data.get(name)
        elif isinstance(data, list):
            if index is not None and len(data) > index:
                data = data[index]
            else:
                data = None
        return data

    # check group count in dict data
    def _get_data_group_count(self, data=None):
        if isinstance(data, list):
            return len(data)

    def _init_repeating_group(self, gdef=None, model_offset=None, data=None, data_offset=0):
        groups = []
        # get group count as a constant
        count = None
        try:
            count = int(gdef[mdef.COUNT])
        except ValueError:
            pass
        except AttributeError:
            self.model.add_error('Count definition %s missing for group %s' % (gdef[mdef.COUNT], gdef[mdef.NAME]))
        if count is None:
            # get count as model point
            count_attr = getattr(self.model, gdef[mdef.COUNT], None)
            if count_attr is None:
                raise ModelError('Count field %s undefined for group %s' % (gdef[mdef.COUNT], gdef[mdef.NAME]))
            count = count_attr.value
        if count is None:
            raise ModelError('Count field %s value not initialized for group %s ' %
                             (gdef[mdef.COUNT], gdef[mdef.NAME]))

        data_group_count = self._get_data_group_count(data=data)
        model_len = self.model.len
        if model_len <= self.model.points_len:
            # if legacy model definition but it is defined in format that number of groups are known, use that count
            # to avoid having to figure out the length in the model data
            if count == 0 and data_group_count:
                count = data_group_count

        # allocate the group entries if the count is available
        if count > 0:
            for i in range(count):
                gdata = self._group_data(data=data, index=i)
                g = self.group_class(gdef=gdef, model=self.model, model_offset=model_offset, data=gdata,
                                     data_offset=data_offset, index=i+1)
                model_offset += g.len
                data_offset += g.len
                groups.append(g)
        elif count == 0:
            data_group_count = self._get_data_group_count(data=data)
            # legacy model definition - need to calculate repeating count by model length
            # compute count based on model len if present, otherwise allocate when set
            model_len = self.model.len
            if model_len:
                gdata = self._group_data(data=data, name=gdef[mdef.NAME], index=0)
                g = self.group_class(gdef=gdef, model=self.model, model_offset=model_offset, data=gdata,
                                     data_offset=data_offset, index=1)
                group_points_len = g.points_len
                # count is model.len-model.points_len/group_points_len
                # (ID and L points are not included in model length)
                repeating_len = model_len - (self.model.points_len - 2)
                if repeating_len > 0:
                    remaining = repeating_len % group_points_len
                    if remaining != 0:
                        raise ModelError('Repeating group count not consistent with model length for model %s,'
                                         'model repeating len = %s, model repeating group len = %s' %
                                         (self.model.model_id, repeating_len, group_points_len))

                    count = int(repeating_len / group_points_len)
                    if count > 0:
                        groups.append(g)
                        model_offset += g.len
                        data_offset += g.len
                    for i in range(count - 1):
                        gdata = self._group_data(data=data, index=(i+1))
                        g = self.group_class(gdef=gdef, model=self.model, model_offset=model_offset, data=gdata,
                                           data_offset=data_offset, index=i+2)
                        model_offset += g.len
                        data_offset += g.len
                        groups.append(g)
        return groups

    def get_dict(self, computed=False):
        d = {}
        for pid, p in self.points.items():
            d[pid] = p.get_value(computed=computed)
        for gid, group in self.groups.items():
            if isinstance(group, list):
                glist = []
                for g in group:
                    glist.append(g.get_dict(computed=computed))
                d[gid] = glist
            else:
                d[gid] = group.get_dict(computed=computed)
        return d

    def set_dict(self, data=None, computed=False, dirty=None):
        groups = []
        group_def = self.gdef
        for k, v in data.items():
            if k in group_def['point_defs']:
                self.points[k].set_value(data=v, computed=computed, dirty=dirty)
            elif k in group_def['group_defs']:
                # write points first as group initialization may depend on point value for group counts
                groups.append(k)
        for k in groups:
            if isinstance(self.groups[k], list):
                i = 0
                for rg in self.groups[k]:
                    rg.set_dict(data[k][i], computed=computed, dirty=dirty)
                    i += 1
            else:
                self.groups[k].set_dict(data[k], computed=computed, dirty=dirty)

    def get_json(self, computed=False):
        return json.dumps(self.get_dict(computed=computed))

    def set_json(self, data=None, computed=False, dirty=None):
        if data is not None:
            d = json.loads(data)
            self.set_dict(d, computed=computed, dirty=dirty)

    def get_mb(self, computed=False):
        data = bytearray()
        for pid, point in self.points.items():
            data.extend(point.get_mb(computed=computed))
        for gid, group in self.groups.items():
            if isinstance(group, list):
                for g in group:
                    data.extend(g.get_mb(computed=computed))
            else:
                data.extend(group.get_mb(computed=computed))
        return bytes(data)

    def set_mb(self, data=None, computed=False, dirty=None):
        if data:
            data_len = len(data)
        else:
            data_len = 0
        offset = 0
        for pid, point in self.points.items():
            if data_len > offset:
                mb_len = point.set_mb(data[offset:], computed=computed, dirty=dirty)
                if mb_len is not None:
                    offset += mb_len * 2

        for gid, group in self.groups.items():
            if isinstance(group, list):
                for g in group:
                    if data_len > offset:
                        mb_len = g.set_mb(data[offset:], computed=computed, dirty=dirty)
                        if mb_len is not None:
                            offset += mb_len * 2
                        else:
                            return None
            else:
                if data_len > offset:
                    mb_len = group.set_mb(data[offset:], computed=computed, dirty=dirty)
                    if mb_len is not None:
                        offset += mb_len * 2
                    else:
                        return None
        return int(offset/2)

    def get_text(self, index=None, parent_index=None):
        txt = ''
        tmp_txt = ''
        for p in self.points:
            tmp_txt = self.points[p].get_text(index, parent_index)
            if tmp_txt:
                txt += tmp_txt
        for g in self.groups:
            if isinstance(self.groups[g], list):
                for i in range(len(self.groups[g])):
                    if index:
                        txt += self.groups[g][i].get_text(i+1, parent_index=index)
                    else:
                        txt += self.groups[g][i].get_text(i+1)
            else:
                txt += self.groups[g].get_text(index)
        return txt


class Model(Group):
    def __init__(self, model_id=None, model_addr=0, model_len=0, model_def=None, data=None, group_class=Group):
        self.model_id = model_id
        self.model_addr = model_addr
        self.model_len = model_len
        self.model_def = model_def
        self.error_info = ''
        self.mid = None
        self.device = None
        self.model = self

        gdef = None
        try:
            if self.model_def is None and model_id is not None:
                self.model_def = get_model_def(model_id)
            if self.model_def is not None:
                gdef = self.model_def.get(mdef.GROUP)
        except Exception as e:
            self.add_error(str(e))

        Group.__init__(self, gdef=gdef, model=self.model, model_offset=0, group_len=self.model_len, data=data,
                       data_offset=0, group_class=group_class)

    def add_error(self, error_info):
        self.error_info = '%s%s\n' % (self.error_info, error_info)

    def get_dict(self, computed=False):
        d = Group.get_dict(self, computed=computed)
        d['mid'] = self.mid
        d['error'] = self.error_info
        d['model_id'] = self.model_id
        return d


class Device(object):
    def __init__(self, model_class=Model):
        self.name = None
        self.did = None
        self.models = {}
        self.model_list = []
        self.model_class = model_class

    def __getattr__(self, attr):
        v = self.models.get(attr)
        if v is None:
            raise AttributeError("'%s' object has no attribute '%s'" % (self.__class__.__name__, attr))
        return v

    def scan(self, data=None):
        pass

    def add_model(self, model):
        # add by model id
        model_id = model.model_id
        model_list = self.models.get(model_id)
        if model_list is None:
            model_list = []
            self.models[model_id] = model_list
        model_list.append(model)
        # add by group id
        gname = model.gname
        if gname is not None:
            model_list = self.models.get(gname)
            if model_list is None:
                model_list = []
                self.models[gname] = model_list
            model_list.append(model)
            # add to model list
            self.model_list.append(model)

        model.device = self

    def delete_models(self):
        self.models = {}
        self.model_list = []

    def get_dict(self, computed=False):
        d = {'name': self.name, 'did': self.did, 'models': []}
        for m in self.model_list:
            d['models'].append(m.get_dict(computed=computed))
        return d

    def get_json(self, computed=False):
        return json.dumps(self.get_dict(computed=computed))

    def get_mb(self, computed=False):
        data = bytearray()
        for m in self.model_list:
            data.extend(m.get_mb(computed=computed))
        return bytes(data)

    def set_mb(self, data=None, computed=False, dirty=None):
        if data:
            data_len = len(data)
        else:
            data_len = 0
        offset = 0
        for m in self.model_list:
            if data_len > offset:
                mb_len = m.set_mb(data[offset:], dirty=dirty, computed=computed)
                if mb_len is not None:
                    offset += mb_len * 2
                else:
                    return None
        return int(offset/2)

    def find_mid(self, mid=None):
        if mid is not None:
            for m in self.model_list:
                if m.mid == mid:
                    return m

    # assumes data should be used to create and initialize the models, does not currently update an initialized device
    def _set_dict(self, data, computed=False, detail=False):
        if self.model_list:
            raise ModelError('Device already initialized')
        self.name = data.get('name')
        models = data.get('models')
        for m in models:
            if detail:
                model_id = m['ID']['value']
            else:
                model_id = m['ID']
            if model_id != mdef.END_MODEL_ID:
                model_def = model_len = None
                try:
                    model_def = get_model_def(model_id)
                except:
                    model_len = m.get('L')
                if not model_len:
                    model_len = 0
                model = Model(model_def=model_def, data=m, model_id=m['ID'], model_len=model_len)
                self.add_model(model=model)

    def get_text(self):
        txt = 'Timestamp: %s\n' % (time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()))
        for m in self.model_list:
            if m.error_info:
                txt += '\nError: ' + m.error_info + '\n'
                continue
            txt += '\nModel: %s (%s)\n\n' % (m.model_def['group']['name'], m.model_id)
            txt += m.get_text()
        return txt
