# -*- coding: utf-8; -*-
################################################################################
#
#  Rattail -- Retail Software Framework
#  Copyright © 2010-2024 Lance Edgar
#
#  This file is part of Rattail.
#
#  Rattail is free software: you can redistribute it and/or modify it under the
#  terms of the GNU General Public License as published by the Free Software
#  Foundation, either version 3 of the License, or (at your option) any later
#  version.
#
#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
#  details.
#
#  You should have received a copy of the GNU General Public License along with
#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
View for running arbitrary import/export jobs
"""

import getpass
import json
import logging
import socket
import subprocess
import sys
import time

import sqlalchemy as sa

from rattail.threads import Thread

import colander
import markdown
from deform import widget as dfwidget
from webhelpers2.html import HTML

from tailbone.views import MasterView


log = logging.getLogger(__name__)


class ImportingView(MasterView):
    """
    View for running arbitrary import/export jobs
    """
    normalized_model_name = 'importhandler'
    model_title = "Import / Export Handler"
    model_key = 'key'
    route_prefix = 'importing'
    url_prefix = '/importing'
    index_title = "Importing / Exporting"
    creatable = False
    editable = False
    deletable = False
    filterable = False
    pageable = False

    configurable = True
    config_title = "Import / Export"

    labels = {
        'host_title': "Data Source",
        'local_title': "Data Target",
        'direction_display': "Direction",
    }

    grid_columns = [
        'host_title',
        'local_title',
        'direction_display',
        'handler_spec',
    ]

    form_fields = [
        'key',
        'local_key',
        'host_key',
        'handler_spec',
        'host_title',
        'local_title',
        'direction_display',
        'models',
    ]

    runjob_form_fields = [
        'handler_spec',
        'host_title',
        'local_title',
        'models',
        'create',
        'update',
        'delete',
        # 'runas',
        'versioning',
        'dry_run',
        'warnings',
    ]

    def get_data(self, session=None):
        app = self.get_rattail_app()
        data = []

        for handler in app.get_designated_import_handlers(
                ignore_errors=True, sort=True):
            data.append(self.normalize(handler))

        return data

    def normalize(self, handler, keep_handler=True):
        data = {
            'key': handler.get_key(),
            'generic_title': handler.get_generic_title(),
            'host_key': handler.host_key,
            'host_title': handler.get_generic_host_title(),
            'local_key': handler.local_key,
            'local_title': handler.get_generic_local_title(),
            'handler_spec': handler.get_spec(),
            'direction': handler.direction,
            'direction_display': handler.direction.capitalize(),
            'safe_for_web_app': handler.safe_for_web_app,
        }

        if keep_handler:
            data['_handler'] = handler

        alternates = getattr(handler, 'alternate_handlers', None)
        if alternates:
            data['alternates'] = []
            for alternate in alternates:
                data['alternates'].append(self.normalize(
                    alternate, keep_handler=keep_handler))

        cmd = self.get_cmd_for_handler(handler, ignore_errors=True)
        if cmd:
            data['cmd'] = ' '.join(cmd)
            data['command'] = cmd[0]
            data['subcommand'] = cmd[1]

        runas = self.get_runas_for_handler(handler)
        if runas:
            data['default_runas'] = runas

        return data

    def configure_grid(self, g):
        super().configure_grid(g)

        g.set_link('host_title')
        g.set_searchable('host_title')

        g.set_link('local_title')
        g.set_searchable('local_title')

        g.set_searchable('handler_spec')

    def get_instance(self):
        """
        Fetch the current model instance by inspecting the route kwargs and
        doing a database lookup.  If the instance cannot be found, raises 404.
        """
        key = self.request.matchdict['key']
        app = self.get_rattail_app()
        handler = app.get_import_handler(key, ignore_errors=True)
        if handler:
            return self.normalize(handler)
        raise self.notfound()

    def get_instance_title(self, handler_info):
        handler = handler_info['_handler']
        return handler.get_generic_title()

    def make_form_schema(self):
        return ImportHandlerSchema()

    def make_form_kwargs(self, **kwargs):
        kwargs = super().make_form_kwargs(**kwargs)

        # nb. this is set as sort of a hack, to prevent SA model
        # inspection logic
        kwargs['renderers'] = {}

        return kwargs

    def configure_form(self, f):
        super().configure_form(f)

        f.set_renderer('models', self.render_models)

    def render_models(self, handler, field):
        handler = handler['_handler']
        items = []
        for key in handler.get_importer_keys():
            items.append(HTML.tag('li', c=[key]))
        return HTML.tag('ul', c=items)

    def template_kwargs_view(self, **kwargs):
        kwargs = super().template_kwargs_view(**kwargs)
        handler_info = kwargs['instance']
        kwargs['handler'] = handler_info['_handler']
        return kwargs

    def runjob(self):
        """
        View for running an import / export job
        """
        handler_info = self.get_instance()
        handler = handler_info['_handler']
        form = self.make_runjob_form(handler_info)

        if self.request.method == 'POST':
            if self.validate_form(form):

                self.cache_runjob_form_values(handler, form)

                try:
                    return self.do_runjob(handler_info, form)
                except Exception as error:
                    self.request.session.flash(str(error), 'error')
                    return self.redirect(self.request.current_route_url())

        return self.render_to_response('runjob', {
            'handler_info': handler_info,
            'handler': handler,
            'form': form,
        })

    def cache_runjob_form_values(self, handler, form):
        handler_key = handler.get_key()

        def make_key(field):
            return 'rattail.importing.{}.{}'.format(handler_key, field)

        for field in form.fields:
            key = make_key(field)
            self.request.session[key] = form.validated[field]

    def read_cached_runjob_values(self, handler, form):
        handler_key = handler.get_key()

        def make_key(field):
            return 'rattail.importing.{}.{}'.format(handler_key, field)

        for field in form.fields:
            key = make_key(field)
            if key in self.request.session:
                form.set_default(field, self.request.session[key])

    def make_runjob_form(self, handler_info, **kwargs):
        """
        Creates a new form for the given model class/instance
        """
        handler = handler_info['_handler']
        factory = self.get_form_factory()
        fields = list(self.runjob_form_fields)
        schema = RunJobSchema()

        kwargs = self.make_runjob_form_kwargs(handler_info, **kwargs)
        form = factory(fields, schema, **kwargs)
        self.configure_runjob_form(handler, form)

        self.read_cached_runjob_values(handler, form)

        return form

    def make_runjob_form_kwargs(self, handler_info, **kwargs):
        route_prefix = self.get_route_prefix()
        handler = handler_info['_handler']
        defaults = {
            'request': self.request,
            'model_instance': handler,
            'cancel_url': self.request.route_url('{}.view'.format(route_prefix),
                                                 key=handler.get_key()),
            # nb. these next 2 are set as sort of a hack, to prevent
            # SA model inspection logic
            'renderers': {},
            'appstruct': handler_info,
        }
        defaults.update(kwargs)
        return defaults

    def configure_runjob_form(self, handler, f):
        self.set_labels(f)

        f.set_readonly('handler_spec')
        f.set_renderer('handler_spec', lambda handler, field: handler.get_spec())

        f.set_readonly('host_title')
        f.set_readonly('local_title')

        keys = handler.get_importer_keys()
        f.set_widget('models', dfwidget.SelectWidget(values=[(k, k) for k in keys],
                                                     multiple=True,
                                                     size=len(keys)))

        allow_create = True
        allow_update = True
        allow_delete = True
        if len(keys) == 1:
            importers = handler.get_importers().values()
            importer = list(importers)[0]
            allow_create = importer.allow_create
            allow_update = importer.allow_update
            allow_delete = importer.allow_delete

        if allow_create:
            f.set_default('create', True)
        else:
            f.remove('create')

        if allow_update:
            f.set_default('update', True)
        else:
            f.remove('update')

        if allow_delete:
            f.set_default('delete', False)
        else:
            f.remove('delete')

        # f.set_default('runas', self.rattail_config.get('rattail', 'runas.default') or '')

        f.set_default('versioning', True)
        f.set_helptext('versioning', "If set, version history will be updated as appropriate")

        f.set_default('dry_run', False)
        f.set_helptext('dry_run', "If set, data will not actually be written")

        f.set_default('warnings', False)
        f.set_helptext('warnings', "If set, will send an email if any diffs")

    def do_runjob(self, handler_info, form):
        handler = handler_info['_handler']
        handler_key = handler.get_key()

        if self.request.POST.get('runjob') == 'true':

            # will invoke handler to run job..

            # ..but only if it is safe to do so
            if not handler.safe_for_web_app:
                self.request.session.flash("Handler is not (yet) safe to run "
                                           "with this tool", 'error')
                return self.redirect(self.request.current_route_url())

            # TODO: this socket progress business was lifted from
            # tailbone.views.batch.core:BatchMasterView.handler_action
            # should probably refactor to share somehow

            # make progress object
            key = 'rattail.importing.{}'.format(handler_key)
            progress = self.make_progress(key)

            # make socket for progress thread to listen to action thread
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.bind(('127.0.0.1', 0))
            sock.listen(1)
            port = sock.getsockname()[1]

            # launch thread to monitor progress
            success_url = self.request.current_route_url()
            thread = Thread(target=self.progress_thread, 
                            args=(sock, success_url, progress))
            thread.start()

            true_cmd = self.make_runjob_cmd(handler, form, 'true', port=port)

            # launch thread to invoke handler
            thread = Thread(target=self.do_runjob_thread,
                            args=(handler, true_cmd, port, progress))
            thread.start()

            return self.render_progress(progress, {
                'can_cancel': False,
                'cancel_url': self.request.current_route_url(),
            })

        else: # explain only
            notes_cmd = self.make_runjob_cmd(handler, form, 'notes')
            self.cache_runjob_notes(handler, notes_cmd)

        return self.redirect(self.request.current_route_url())

    def do_runjob_thread(self, handler, cmd, port, progress):

        # invoke handler command via subprocess
        try:
            result = subprocess.run(cmd, check=True,
                                    stdout=subprocess.PIPE,
                                    stderr=subprocess.STDOUT)
            output = result.stdout.decode('utf_8').strip()

        except Exception as error:
            log.warning("failed to invoke handler cmd: %s", cmd, exc_info=True)
            if progress:
                progress.session.load()
                progress.session['error'] = True
                msg = """\
{} failed!  Here is the command I tried to run:

```
{}
```

And here is the output:

```
{}
```
""".format(handler.direction.capitalize(),
           ' '.join(cmd),
           error.stdout.decode('utf_8').strip())
                msg = markdown.markdown(msg, extensions=['fenced_code'])
                msg = HTML.literal(msg)
                msg = HTML.tag('div', class_='tailbone-markdown', c=[msg])
                progress.session['error_msg'] = msg
                progress.session.save()

        else: # success

            if progress:
                progress.session.load()
                msg = self.get_runjob_success_msg(handler, output)
                progress.session['complete'] = True
                progress.session['success_url'] = self.request.current_route_url()
                progress.session['success_msg'] = msg
                progress.session.save()

            suffix = "\n\n.".encode('utf_8')
            cxn = socket.create_connection(('127.0.0.1', port))
            data = json.dumps({
                'everything_complete': True,
            })
            data = data.encode('utf_8')
            cxn.send(data)
            cxn.send(suffix)
            cxn.close()

    def get_runjob_success_msg(self, handler, output):
        notes = """\
{} went okay, here is the output:

```
{}
```
""".format(handler.direction.capitalize(), output)

        notes = markdown.markdown(notes, extensions=['fenced_code'])
        notes = HTML.literal(notes)
        return HTML.tag('div', class_='tailbone-markdown', c=[notes])

    def get_cmd_for_handler(self, handler, ignore_errors=False):
        return handler.get_cmd(ignore_errors=ignore_errors)

    def get_runas_for_handler(self, handler):
        handler_key = handler.get_key()
        runas = self.rattail_config.get('rattail.importing',
                                        '{}.runas'.format(handler_key))
        if runas:
            return runas
        return self.rattail_config.get('rattail', 'runas.default')

    def make_runjob_cmd(self, handler, form, typ, port=None):
        command, subcommand = self.get_cmd_for_handler(handler)
        runas = self.get_runas_for_handler(handler)
        data = form.validated

        if typ == 'true':
            cmd = [
                '{}/bin/{}'.format(sys.prefix, command),
                '--config={}/app/quiet.conf'.format(sys.prefix),
                '--progress',
                '--progress-socket=127.0.0.1:{}'.format(port),
            ]
        else:
            cmd = [
                'sudo', '-u', getpass.getuser(),
                'bin/{}'.format(command),
                '-c', 'app/quiet.conf',
                '-P',
            ]

        if runas:
            if typ == 'true':
                cmd.append('--runas={}'.format(runas))
            else:
                cmd.extend(['--runas', runas])

        cmd.append(subcommand)

        cmd.extend(data['models'])

        if data['create']:
            if typ == 'true':
                cmd.append('--create')
        else:
            cmd.append('--no-create')

        if data['update']:
            if typ == 'true':
                cmd.append('--update')
        else:
            cmd.append('--no-update')

        if data['delete']:
            cmd.append('--delete')
        else:
            if typ == 'true':
                cmd.append('--no-delete')

        if data['versioning']:
            if typ == 'true':
                cmd.append('--versioning')
        else:
            cmd.append('--no-versioning')

        if data['dry_run']:
            cmd.append('--dry-run')

        if data['warnings']:
            if typ == 'true':
                cmd.append('--warnings')
            else:
                cmd.append('-W')

        return cmd

    def cache_runjob_notes(self, handler, notes_cmd):
        notes = """\
You can run this {direction} job manually via command line:

```sh
cd {prefix}
{cmd}
```
""".format(direction=handler.direction,
           prefix=sys.prefix,
           cmd=' '.join(notes_cmd))

        self.request.session['rattail.importing.runjob.notes'] = markdown.markdown(
            notes, extensions=['fenced_code', 'codehilite'])

    def configure_get_context(self):
        app = self.get_rattail_app()
        handlers_data = []

        for handler in app.get_designated_import_handlers(
                with_alternates=True,
                ignore_errors=True, sort=True):

            data = self.normalize(handler, keep_handler=False)

            data['spec_options'] = [handler.get_spec()]
            for alternate in handler.alternate_handlers:
                data['spec_options'].append(alternate.get_spec())
            data['spec_options'].sort()

            handlers_data.append(data)

        return {
            'handlers_data': handlers_data,
        }

    def configure_gather_settings(self, data):
        settings = []

        for handler in json.loads(data['handlers']):
            key = handler['key']

            settings.extend([
                {'name': 'rattail.importing.{}.handler'.format(key),
                 'value': handler['handler_spec']},
                {'name': 'rattail.importing.{}.cmd'.format(key),
                 'value': '{} {}'.format(handler['command'],
                                         handler['subcommand'])},
                {'name': 'rattail.importing.{}.runas'.format(key),
                 'value': handler['default_runas']},
            ])

        return settings

    def configure_remove_settings(self):
        app = self.get_rattail_app()
        model = self.model
        session = self.Session()

        to_delete = session.query(model.Setting)\
                           .filter(sa.or_(
                               model.Setting.name.like('rattail.importing.%.handler'),
                               model.Setting.name.like('rattail.importing.%.cmd'),
                               model.Setting.name.like('rattail.importing.%.runas')))\
                           .all()

        for setting in to_delete:
            app.delete_setting(session, setting)

    @classmethod
    def defaults(cls, config):
        cls._defaults(config)
        cls._importing_defaults(config)

    @classmethod
    def _importing_defaults(cls, config):
        route_prefix = cls.get_route_prefix()
        permission_prefix = cls.get_permission_prefix()
        instance_url_prefix = cls.get_instance_url_prefix()

        # run job
        config.add_tailbone_permission(permission_prefix,
                                       '{}.runjob'.format(permission_prefix),
                                       "Run an arbitrary Import / Export Job")
        config.add_route('{}.runjob'.format(route_prefix),
                         '{}/runjob'.format(instance_url_prefix))
        config.add_view(cls, attr='runjob', 
                        route_name='{}.runjob'.format(route_prefix),
                        permission='{}.runjob'.format(permission_prefix))


class ImportHandlerSchema(colander.MappingSchema):

    host_key = colander.SchemaNode(colander.String())

    local_key = colander.SchemaNode(colander.String())

    host_title = colander.SchemaNode(colander.String())

    local_title = colander.SchemaNode(colander.String())

    handler_spec = colander.SchemaNode(colander.String())


class RunJobSchema(colander.MappingSchema):

    handler_spec = colander.SchemaNode(colander.String(),
                                       missing=colander.null)
    
    host_title = colander.SchemaNode(colander.String(),
                                       missing=colander.null)

    local_title = colander.SchemaNode(colander.String(),
                                       missing=colander.null)

    models = colander.SchemaNode(colander.List())

    create = colander.SchemaNode(colander.Bool())

    update = colander.SchemaNode(colander.Bool())

    delete = colander.SchemaNode(colander.Bool())

    # runas = colander.SchemaNode(colander.String())

    versioning = colander.SchemaNode(colander.Bool())

    dry_run = colander.SchemaNode(colander.Bool())

    warnings = colander.SchemaNode(colander.Bool())


def defaults(config, **kwargs):
    base = globals()

    ImportingView = kwargs.get('ImportingView', base['ImportingView'])
    ImportingView.defaults(config)


def includeme(config):
    defaults(config)
