# This file is part of Tryton.  The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import json
import os
import logging
from lxml import etree
from sql import Literal, Null

from trytond.cache import Cache, MemoryCache
from trytond.i18n import gettext
from trytond.model import Index, ModelSQL, ModelView, fields, sequence_ordered
from trytond.model.exceptions import ValidationError
from trytond.pool import Pool
from trytond.pyson import PYSON, Bool, Eval, If, PYSONDecoder
from trytond.rpc import RPC
from trytond.tools import file_open
from trytond.transaction import Transaction
from trytond.wizard import Button, StateView, Wizard

from ..action import DomainError, ViewError

logger = logging.getLogger(__name__)

class XMLError(ValidationError):
    pass


class View(
        fields.fmany2one(
            'model_ref', 'model', 'ir.model,model', "Model",
            ondelete='CASCADE'),
        fields.fmany2one(
            'field_children', 'field_childs,model',
            'ir.model.field,name,model',
            "Children Field",
            domain=[
                ('model', '=', Eval('model')),
                ],
            states={
                'invisible': Eval('type') != 'tree',
                }),
        fields.fmany2one(
            'module_ref', 'module', 'ir.module,name', "Module",
            readonly=True, ondelete='CASCADE'),
        sequence_ordered('priority', "Priority"), ModelSQL, ModelView):
    "View"
    __name__ = 'ir.ui.view'
    model = fields.Char('Model', states={
            'required': Eval('type') != 'board',
            })
    type = fields.Selection([
            (None, ''),
            ('tree', 'Tree'),
            ('form', 'Form'),
            ('graph', 'Graph'),
            ('calendar', 'Calendar'),
            ('board', 'Board'),
            ('list-form', "List Form"),
            ], 'View Type',
        domain=[
            If(Bool(Eval('inherit')),
                ('type', '=', None),
                ('type', '!=', None)),
            ],
        depends=['inherit'])
    type_string = type.translated('type')
    data = fields.Text('Data')
    name = fields.Char('Name', states={
            'invisible': ~(Eval('module') & Eval('name')),
            }, depends=['module'], readonly=True)
    arch = fields.Function(fields.Text('View Architecture', states={
                'readonly': Bool(Eval('name')),
                }, depends=['name']), 'get_arch', setter='set_arch')
    basis = fields.Function(fields.Boolean("Basis"), 'get_basis')
    inherit = fields.Many2One('ir.ui.view', 'Inherited View',
            ondelete='CASCADE')
    extensions = fields.One2Many(
        'ir.ui.view', 'inherit', "Extensions",
        filter=[
            ('basis', '=', False),
            ],
        domain=[
            ('model', '=', Eval('model')),
            ('type', '=', None),
            ],
        states={
            'invisible': ~Eval('type'),
            },
        order=[('id', None)])
    field_childs = fields.Char('Children Field', states={
            'invisible': Eval('type') != 'tree',
            }, depends=['type'])
    module = fields.Char('Module', states={
            'invisible': ~Eval('module'),
            }, readonly=True)
    domain = fields.Char('Domain', states={
            'invisible': ~Eval('inherit'),
            }, depends=['inherit'])
    _get_rng_cache = MemoryCache('ir_ui_view.get_rng', context=False)
    _view_get_cache = Cache('ir_ui_view.view_get')
    __module_index = None

    @classmethod
    def __setup__(cls):
        super(View, cls).__setup__()
        table = cls.__table__()
        cls.priority.required = True

        cls.__rpc__['view_get'] = RPC(instantiate=0, cache=dict(days=1))
        cls._buttons.update({
                'show': {
                    'readonly': Eval('type') != 'form',
                    'invisible': ~Eval('basis', False),
                    'depends': ['type', 'basis'],
                    },
                })
        cls._sql_indexes.update({
                Index(table,
                    (table.model, Index.Equality()),
                    (table.inherit, Index.Equality())),
                Index(
                    table,
                    (table.id, Index.Equality()),
                    (table.inherit, Index.Equality())),
                })

    @staticmethod
    def default_priority():
        return 16

    @staticmethod
    def default_module():
        return Transaction().context.get('module') or ''

    def get_basis(self, name):
        return not self.inherit or self.model != self.inherit.model

    @classmethod
    def domain_basis(cls, domain, tables):
        table, _ = tables[None]
        if 'inherit' not in tables:
            inherit = cls.__table__()
            tables['inherit'] = {
                None: (inherit, table.inherit == inherit.id),
                }
        else:
            inherit, _ = tables['inherit'][None]
        expression = (table.inherit == Null) | (table.model != inherit.model)

        _, operator, value = domain
        if operator in {'=', '!='}:
            if (operator == '=') != value:
                expression = ~expression
        elif operator in {'in', 'not in'}:
            if True in value and False not in value:
                pass
            elif False in value and True not in value:
                expression = ~expression
            else:
                expression = Literal(True)
        else:
            expression = Literal(True)
        return expression

    def get_rec_name(self, name):
        return '%s (%s)' % (
            self.model_ref.rec_name,
            self.inherit.rec_name if self.inherit else self.type_string)

    @classmethod
    def search_rec_name(cls, name, clause):
        return [('model_ref.rec_name', *clause[1:])]

    @classmethod
    @ModelView.button_action('ir.act_view_show')
    def show(cls, views):
        pass

    @classmethod
    def get_rng(cls, type_):
        key = (cls.__name__, type_)
        rng = cls._get_rng_cache.get(key)
        if rng is None:
            if type_ == 'list-form':
                type_ = 'form'
            rng_name = os.path.join(os.path.dirname(__file__), type_ + '.rng')
            with open(rng_name, 'rb') as fp:
                rng = etree.fromstring(fp.read())
            cls._get_rng_cache.set(key, rng)
        return rng

    @property
    def rng_type(self):
        if self.inherit:
            return self.inherit.rng_type
        return self.type

    @classmethod
    def validate(cls, views):
        super(View, cls).validate(views)
        cls.check_xml(views)

    @classmethod
    def check_xml(cls, views):
        "Check XML"
        for view in views:
            if not view.arch:
                continue
            xml = view.arch.strip()
            if not xml:
                continue
            tree = etree.fromstring(xml)

            if hasattr(etree, 'RelaxNG'):
                validator = etree.RelaxNG(etree=cls.get_rng(view.rng_type))
                if not validator.validate(tree):
                    error_log = '\n'.join(map(str,
                            validator.error_log.filter_from_errors()))
                    raise XMLError(
                        gettext('ir.msg_view_invalid_xml', name=view.rec_name),
                        error_log)
            root_element = tree.getroottree().getroot()

            # validate pyson attributes
            validates = {
                'states': fields.states_validate,
            }

            def encode(element):
                for attr in ('states', 'domain', 'spell'):
                    if not element.get(attr):
                        continue
                    try:
                        value = PYSONDecoder().decode(element.get(attr))
                        validates.get(attr, lambda a: True)(value)
                    except Exception as e:
                        error_log = '%s: <%s %s="%s"/>' % (
                            e, element.get('id') or element.get('name'), attr,
                            element.get(attr))
                        raise XMLError(
                            gettext(
                                'ir.msg_view_invalid_xml', name=view.rec_name),
                            error_log) from e
                for child in element:
                    encode(child)
            encode(root_element)

    def get_arch(self, name):
        value = None
        if self.name and self.module:
            path = os.path.join(self.module, 'view', self.name + '.xml')
            try:
                with file_open(path,
                        subdir='modules', mode='r', encoding='utf-8') as fp:
                    value = fp.read()
            except IOError:
                pass
        if not value:
            value = self.data
        return value

    @classmethod
    def set_arch(cls, views, name, value):
        cls.write(views, {'data': value})

    @classmethod
    def delete(cls, views):
        super(View, cls).delete(views)
        # Restart the cache
        cls._view_get_cache.clear()
        ModelView._fields_view_get_cache.clear()

    @classmethod
    def create(cls, vlist):
        views = super(View, cls).create(vlist)
        # Restart the cache
        cls._view_get_cache.clear()
        ModelView._fields_view_get_cache.clear()
        return views

    @classmethod
    def write(cls, views, values, *args):
        super(View, cls).write(views, values, *args)
        # Restart the cache
        cls._view_get_cache.clear()
        ModelView._fields_view_get_cache.clear()

    @property
    def _module_index(self):
        from trytond.modules import create_graph, get_modules
        if self.__class__.__module_index is None:
            graph = create_graph(get_modules(with_test=Pool.test))
            modules = [m.name for m in graph]
            self.__class__.__module_index = {
                m: i for i, m in enumerate(reversed(modules))}
        return self.__class__.__module_index

    def view_get(self, model=None):
        key = (self.id, model)
        result = self._view_get_cache.get(key)
        if result:
            return result
        if self.inherit:
            if self.inherit.model == model:
                return self.inherit.view_get(model=model)
            else:
                arch = self.inherit.view_get(self.inherit.model)['arch']
        else:
            arch = self.arch

        views = self.__class__.search(['OR', [
                    ('inherit', '=', self.id),
                    ('model', '=', model),
                    ], [
                    ('id', '=', self.id),
                    ('inherit', '!=', None),
                    ],
                ])
        views.sort(
            key=lambda v: self._module_index.get(v.module, -1), reverse=True)
        parser = etree.XMLParser(remove_comments=True, resolve_entities=False)
        # logger.info("ARCH:%s",arch)
        # logger.info("MODEL:%s",model)
        # logger.info("PARSER:%s",etree)
        tree = etree.fromstring(arch, parser=parser)
        decoder = PYSONDecoder({'context': Transaction().context})
        for view in views:
            if view.domain and not decoder.decode(view.domain):
                continue
            if not view.arch or not view.arch.strip():
                continue
            tree_inherit = etree.fromstring(view.arch, parser=parser)
            tree = self.inherit_apply(tree, tree_inherit)
        if model:
            root = tree.getroottree().getroot()
            self._translate(root, model, Transaction().language)
        arch = etree.tostring(tree, encoding='utf-8').decode('utf-8')
        result = {
            'type': self.rng_type,
            'view_id': self.id,
            'arch': arch,
            'field_childs': self.field_childs,
            }
        self._view_get_cache.set(key, result)
        return result

    @classmethod
    def inherit_apply(cls, tree, inherit):
        root_inherit = inherit.getroottree().getroot()
        for element in root_inherit:
            expr = element.get('expr')
            targets = tree.xpath(expr)
            assert targets, "No elements found for expression %r" % expr
            for target in targets:
                position = element.get('position', 'inside')
                new_tree = getattr(cls, '_inherit_apply_%s' % position)(
                    tree, element, target)
                if new_tree:
                    tree = new_tree
        return tree

    @classmethod
    def _inherit_apply_replace(cls, tree, element, target):
        parent = target.getparent()
        if parent is None:
            tree, = element
            return tree
        cls._inherit_apply_after(tree, element, target)
        parent.remove(target)

    @classmethod
    def _inherit_apply_replace_attributes(cls, tree, element, target):
        child, = element
        for attr in child.attrib:
            target.set(attr, child.get(attr))

    @classmethod
    def _inherit_apply_inside(cls, tree, element, target):
        target.extend(list(element))

    @classmethod
    def _inherit_apply_after(cls, tree, element, target):
        parent = target.getparent()
        next_ = target.getnext()
        if next_ is not None:
            for child in element:
                index = parent.index(next_)
                parent.insert(index, child)
        else:
            parent.extend(list(element))

    @classmethod
    def _inherit_apply_before(cls, tree, element, target):
        parent = target.getparent()
        for child in element:
            index = parent.index(target)
            parent.insert(index, child)

    @classmethod
    def _translate(cls, element, model, language):
        pool = Pool()
        Translation = pool.get('ir.translation')
        for attr in ['string', 'sum', 'confirm', 'help']:
            if element.get(attr):
                translation = Translation.get_source(
                    model, 'view', language, element.get(attr))
                if translation:
                    element.set(attr, translation)
        for child in element:
            cls._translate(child, model, language)


class ShowViewStart(ModelView):
    'Show view'
    __name__ = 'ir.ui.view.show.start'
    __no_slots__ = True


class ShowView(Wizard):
    'Show view'
    __name__ = 'ir.ui.view.show'

    class ShowStateView(StateView):

        def __init__(self, model_name, buttons):
            StateView.__init__(self, model_name, None, buttons)

        def get_view(self, wizard, state_name):
            pool = Pool()
            View = pool.get('ir.ui.view')
            view_id = Transaction().context.get('active_id')
            if not view_id:
                # Set type to please ModuleTestCase.test_wizards
                return {'type': 'form'}
            view = View(view_id)
            Model = pool.get(view.model)
            return Model.fields_view_get(view_id=view.id)

        def get_defaults(self, wizard, state_name, fields):
            return {}

    start = ShowStateView('ir.ui.view.show.start', [
            Button('Close', 'end', 'tryton-close', default=True),
            ])


class ViewTreeWidth(
        fields.fmany2one(
            'model_ref', 'model', 'ir.model,model', "Model",
            required=True, ondelete='CASCADE'),
        fields.fmany2one(
            'field_ref', 'field,model', 'ir.model.field,name,model', "Field",
            required=True, ondelete='CASCADE',
            domain=[
                ('model', '=', Eval('model')),
                ]),
        ModelSQL, ModelView):
    "View Tree Width"
    __name__ = 'ir.ui.view_tree_width'
    model = fields.Char('Model', required=True)
    field = fields.Char('Field', required=True)
    user = fields.Many2One('res.user', 'User', required=True,
        ondelete='CASCADE')
    width = fields.Integer('Width', required=True)

    @classmethod
    def __setup__(cls):
        super(ViewTreeWidth, cls).__setup__()
        table = cls.__table__()
        cls.__rpc__.update({
                'set_width': RPC(readonly=False),
                })
        cls._sql_indexes.add(
            Index(
                table,
                (table.user, Index.Equality()),
                (table.model, Index.Equality()),
                (table.field, Index.Equality())))

    def get_rec_name(self, name):
        return f'{self.field_ref.rec_name} @ {self.model_ref.rec_name}'

    @classmethod
    def search_rec_name(cls, name, clause):
        operator = clause[1]
        if operator.startswith('!') or operator.startswith('not '):
            bool_op = 'AND'
        else:
            bool_op = 'OR'
        return [bool_op,
            ('model_ref.rec_name', *clause[1:]),
            ('field_ref.rec_name', *clause[1:]),
            ]

    @classmethod
    def delete(cls, records):
        ModelView._fields_view_get_cache.clear()
        super(ViewTreeWidth, cls).delete(records)

    @classmethod
    def create(cls, vlist):
        res = super(ViewTreeWidth, cls).create(vlist)
        ModelView._fields_view_get_cache.clear()
        return res

    @classmethod
    def write(cls, records, values, *args):
        super(ViewTreeWidth, cls).write(records, values, *args)
        ModelView._fields_view_get_cache.clear()

    @classmethod
    def set_width(cls, model, fields):
        '''
        Set width for the current user on the model.
        fields is a dictionary with key: field name and value: width.
        '''
        records = cls.search([
            ('user', '=', Transaction().user),
            ('model', '=', model),
            ('field', 'in', list(fields.keys())),
            ])
        cls.delete(records)

        to_create = []
        for field in list(fields.keys()):
            to_create.append({
                    'model': model,
                    'field': field,
                    'user': Transaction().user,
                    'width': fields[field],
                    })
        if to_create:
            cls.create(to_create)


class ViewTreeOptional(ModelSQL, ModelView):
    "View Tree Optional"
    __name__ = 'ir.ui.view_tree_optional'
    view_id = fields.Many2One(
        'ir.ui.view', "View ID", required=True, ondelete='CASCADE')
    user = fields.Many2One(
        'res.user', "User", required=True, ondelete='CASCADE')
    field = fields.Char("Field", required=True)
    value = fields.Boolean("Value")

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.__rpc__.update({
                'set_optional': RPC(readonly=False),
                })
        table = cls.__table__()
        cls._sql_indexes.add(
            Index(
                table,
                (table.user, Index.Equality()),
                (table.view_id, Index.Equality())))

    @classmethod
    def validate_fields(cls, records, fields_names):
        super().validate_fields(records, fields_names)
        cls.check_view_id(records, fields_names)

    @classmethod
    def check_view_id(cls, records, fields_names=None):
        if fields_names and 'view_id' not in fields_names:
            return
        for record in records:
            if record.view_id and record.view_id.rng_type != 'tree':
                raise ViewError(gettext(
                        'ir.msg_view_tree_optional_type',
                        view=record.view_id.rec_name))

    @classmethod
    def create(cls, vlist):
        records = super().create(vlist)
        ModelView._fields_view_get_cache.clear()
        return records

    @classmethod
    def write(cls, *args):
        super().write(*args)
        ModelView._fields_view_get_cache.clear()

    @classmethod
    def delete(cls, records):
        ModelView._fields_view_get_cache.clear()
        super().delete(records)

    @classmethod
    def set_optional(cls, view_id, fields):
        "Store optional field that must be displayed"
        user = Transaction().user
        records = cls.search([
                ('view_id', '=', view_id),
                ('user', '=', user),
                ])
        cls.delete(records)
        to_create = []
        for field, value in fields.items():
            to_create.append({
                    'view_id': view_id,
                    'user': user,
                    'field': field,
                    'value': bool(value),
                    })
        if to_create:
            cls.create(to_create)


class ViewTreeState(ModelSQL, ModelView):
    'View Tree State'
    __name__ = 'ir.ui.view_tree_state'
    _rec_name = 'model'
    model = fields.Char('Model', required=True)
    domain = fields.Char('Domain', required=True)
    user = fields.Many2One('res.user', 'User', required=True,
            ondelete='CASCADE')
    child_name = fields.Char('Child Name')
    nodes = fields.Text('Expanded Nodes')
    selected_nodes = fields.Text('Selected Nodes')

    @classmethod
    def __setup__(cls):
        super(ViewTreeState, cls).__setup__()
        cls.__rpc__.update({
                'set': RPC(readonly=False, check_access=False),
                'get': RPC(check_access=False, cache=dict(days=1)),
                })

        table = cls.__table__()
        cls._sql_indexes.add(
            Index(
                table,
                (table.user, Index.Equality()),
                (table.model, Index.Equality()),
                (table.child_name, Index.Equality()),
                (table.domain, Index.Equality())))

    @staticmethod
    def default_nodes():
        return '[]'

    @staticmethod
    def default_selected_nodes():
        return '[]'

    @classmethod
    def set(cls, model, domain, child_name, nodes, selected_nodes):
        # Normalize the json domain
        logger.info("VIEWTREESTATE:%s",domain)
        logger.info("VIEWTREESTATE2:%s",child_name)
        logger.info("VIEWTREESTATE3:%s",nodes)
        logger.info("VIEWTREESTATE4:%s",cls.__table__())
        domain = json.dumps(json.loads(domain), separators=(',', ':'))
        current_user = Transaction().user
        records = cls.search([
                ('user', '=', current_user),
                ('model', '=', model),
                ('domain', '=', domain),
                ('child_name', '=', child_name),
                ])
        cls.delete(records)
        cls.create([{
                    'user': current_user,
                    'model': model,
                    'domain': domain,
                    'child_name': child_name,
                    'nodes': nodes,
                    'selected_nodes': selected_nodes,
                    }])

    @classmethod
    def get(cls, model, domain, child_name):
        # Normalize the json domain
        domain = json.dumps(json.loads(domain), separators=(',', ':'))
        current_user = Transaction().user
        try:
            expanded_info, = cls.search([
                    ('user', '=', current_user),
                    ('model', '=', model),
                    ('domain', '=', domain),
                    ('child_name', '=', child_name),
                    ],
                limit=1)
        except ValueError:
            return (cls.default_nodes(), cls.default_selected_nodes())
        state = cls(expanded_info)
        return (state.nodes or cls.default_nodes(),
            state.selected_nodes or cls.default_selected_nodes())


class ViewSearch(ModelSQL, ModelView):
    "View Search"
    __name__ = 'ir.ui.view_search'

    name = fields.Char('Name', required=True)
    model = fields.Char('Model', required=True)
    domain = fields.Char('Domain', help="The PYSON domain.")
    user = fields.Many2One('res.user', 'User', ondelete='CASCADE')

    @classmethod
    def __setup__(cls):
        super(ViewSearch, cls).__setup__()
        cls.__rpc__.update({
                'get': RPC(check_access=False),
                'set': RPC(check_access=False, readonly=False),
                'unset': RPC(check_access=False, readonly=False),
                })

    @classmethod
    def __register__(cls, module):
        super().__register__(module)
        table_h = cls.__table_handler__(module)

        # Migration from 5.6: remove user required
        table_h.not_null_action('user', 'remove')

    @staticmethod
    def default_user():
        return Transaction().user

    @classmethod
    def validate_fields(cls, searches, field_names):
        super().validate_fields(searches, field_names)
        cls.check_domain(searches, field_names)

    @classmethod
    def check_domain(cls, searches, field_names):
        decoder = PYSONDecoder()
        if field_names and 'domain' not in field_names:
            return
        for search in searches:
            try:
                value = decoder.decode(search.domain)
            except Exception as exception:
                raise DomainError(
                    gettext('ir.msg_view_search_invalid_domain',
                        domain=search.domain,
                        search=search.rec_name)) from exception
            if isinstance(value, PYSON):
                if not value.types() == set([list]):
                    raise DomainError(
                        gettext('ir.msg_view_search_invalid_domain',
                            domain=search.domain,
                            search=search.rec_name))
            elif not isinstance(value, list):
                raise DomainError(
                    gettext('ir.msg_view_search_invalid_domain',
                        domain=search.domain,
                        search=search.rec_name))
            else:
                try:
                    fields.domain_validate(value)
                except Exception as exception:
                    raise DomainError(
                        gettext('ir.msg_view_search_invalid_domain',
                            domain=search.domain,
                            search=search.rec_name)) from exception

    @classmethod
    def get(cls):
        decoder = PYSONDecoder()
        user = Transaction().user
        searches = cls.search_read(['OR',
                ('user', '=', user),
                ('user', '=', None),
                ],
            order=[('model', 'ASC'), ('name', 'ASC')],
            fields_names=['id', 'name', 'model', 'domain', '_delete'])
        result = {}
        for search in searches:
            result.setdefault(search['model'], []).append((
                    search['id'],
                    search['name'],
                    decoder.decode(search['domain']),
                    search['_delete']))
        return result

    @classmethod
    def set(cls, name, model, domain):
        user = Transaction().user
        search, = cls.create([{
                    'name': name,
                    'model': model,
                    'domain': domain,
                    'user': user,
                    }])
        return search.id

    @classmethod
    def unset(cls, id):
        user = Transaction().user
        cls.delete(cls.search([
                    ('id', '=', id),
                    ('user', '=', user),
                    ]))
