#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Copyright 2007-2013 Glencoe Software, Inc. All rights reserved.
# Use is subject to license terms supplied in LICENSE.txt
"""
prefs plugin

Plugin read by omero.cli.Cli during initialization. The method(s)
defined here will be added to the Cli class for later use.

The pref plugin makes use of prefs.class from the common component.
"""
from __future__ import division

from builtins import zip
from builtins import str
from past.utils import old_div
import sys
import traceback
import os

from omero.cli import CLI
from omero.cli import BaseControl
from omero.cli import ExistingFile
from omero.cli import NonZeroReturnCode
from omero.config import ConfigXml
from omero.util import edit_path, get_omero_userdir
from omero.util.decorators import wraps
from omero.util.upgrade_check import UpgradeCheck
import portalocker
from omero_ext.argparse import SUPPRESS
from omero_ext.path import path

import omero.java

HELP = """Commands for server configuration

A config.xml file will be modified under your etc/grid directory. If you do
not have one, "upgrade" will create a new 4.2 configuration file.

The configuration values are used by `omero admin {start,deploy}` to set
properties on launch. See etc/grid/(win)default.xml. The "Profile" block
contains a reference to "__ACTIVE__" which is the current value in config.xml

By default, OMERO.grid will use the file in etc/grid/config.xml. If you would
like to configure your system to use $HOME/omero/config.xml, you will need to
modify the application descriptor.

Environment variables:
    OMERO_CONFIG - Changes the active profile

"""


def getprefs(args, dir):
    """
    Kept around temporarily for upgrading users from pre-4.2 configurations.
    """
    if not isinstance(args, list):
        raise Exception("Not a list")
    cmd = ["prefs"] + list(args)
    return omero.java.run(cmd, chdir=dir)


def _make_open_and_close_config(func, allow_readonly):
    def open_and_close_config(*args, **kwargs):
        args = list(args)
        self = args[0]
        argp = args[1]
        config = None
        if len(args) == 2:
            config = self.open_config(argp)
            if not allow_readonly:
                self.die_on_ro(config)
            args.append(config)
        try:
            return func(*args, **kwargs)
        finally:
            if config:
                config.close()
    return open_and_close_config


def with_config(func):
    """
    opens a config and passes it as the second argument.
    """
    return wraps(func)(_make_open_and_close_config(func, True))


def with_rw_config(func):
    """
    opens a config and passes it as the second argument.
    Requires that the returned config be writeable
    """
    return wraps(func)(_make_open_and_close_config(func, False))


class WriteableConfigControl(BaseControl):
    """
    Base class for controls which need write access to the OMERO configuration
    using the @with_rw_config decorator

    Note BaseControl should be used for read-only access using @with_config
    """

    def die_on_ro(self, config):
        if not config.save_on_close:
            self.ctx.die(333, "Cannot modify %s" % config.filename)

    def run_upgrade_check(self, config, agent):
        properties = config.as_map()
        upgrade_url = properties.get("omero.upgrades.url", None)
        if upgrade_url:
            uc = UpgradeCheck(agent, url=upgrade_url)
        else:
            uc = UpgradeCheck(agent)
        uc.run()
        return uc


class PrefsControl(WriteableConfigControl):

    def _configure(self, parser):
        parser.add_argument(
            "--source", help="Configuration file to be used. Default:"
            " etc/grid/config.xml")

        sub = parser.sub()

        parser.add(
            sub, self.all,
            "List all profiles in the current config.xml file.")

        default = sub.add_parser(
            "def", help="List (or set) the current active profile.",
            description="List (or set) the current active profile.")
        default.set_defaults(func=self.default)
        default.add_argument(
            "NAME", nargs="?",
            help="Name of the profile which should be made the new active"
            " profile.")

        list = parser.add(
            sub, self.list,
            "List all key-value pairs from the current profile (deprecated)")
        list.set_defaults(func=self.list)

        get = parser.add(
            sub, self.get,
            "Get key-value pairs from the current profile. All by default")
        get.set_defaults(func=self.get)
        get.add_argument(
            "KEY", nargs="*", help="Names of keys in the current profile")

        for x in [get, list]:
            secrets = x.add_mutually_exclusive_group()
            secrets.add_argument(
                "--show-password", action="store_true",
                help="Show values of sensitive keys (passwords, "
                "tokens, etc.) in the current profile")
            secrets.add_argument(
                "--hide-password", action="store_false",
                dest="show_password", help=SUPPRESS)

        set = parser.add(
            sub, self.set,
            "Set key-value pair in the current profile. Omit the"
            " value to remove the key.")
        append = parser.add(
            sub, self.append, "Append value to a key in the current profile.")
        remove = parser.add(
            sub, self.remove,
            "Remove value from a key in the current profile.")

        for x in [set, append, remove]:
            x.add_argument(
                "KEY", help="Name of the key in the current profile")
            # report is intended for use with cfg mgmt tools
            x.add_argument("--report", action="store_true",
                           help="Report if changes are made")
        set.add_argument(
            "-f", "--file", type=ExistingFile('r'),
            help="Load value from file")
        set.add_argument(
            "VALUE", nargs="?",
            help="Value to be set. If it is missing, the key will be removed")
        append.add_argument(
            "--set", action="store_true",
            help="Append only if not already in the list")
        append.add_argument("VALUE", help="Value to be appended")
        remove.add_argument("VALUE", help="Value to be removed")

        drop = parser.add(
            sub, self.drop, "Remove the profile from the configuration file")
        drop.add_argument("NAME", help="Name of the profile to remove")

        parser.add(sub, self.keys, "List all keys for the current profile")

        load = parser.add(
            sub, self.load,
            "Read into current profile from a file or standard input")
        load.add_argument(
            "-q", action="store_true", help="No error on conflict")
        load.add_argument(
            "file", nargs="*", type=ExistingFile('r'), default="-",
            help="Files to read from. Default to standard input if not"
            " specified")

        parse = parser.add(
            sub, self.parse,
            "Parse the configuration properties from the etc/omero.properties"
            " file and Web properties for readability.")
        parse.add_argument(
            "-f", "--file", type=ExistingFile('r'),
            help="Alternative location for a Java properties file")
        parse_group = parse.add_mutually_exclusive_group()
        parse_group.add_argument(
            "--defaults", action="store_true",
            help="Show key/value configuration defaults")
        parse_group.add_argument(
            "--rst", action="store_true",
            help="Generate reStructuredText from omero.properties")
        parse_group.add_argument(
            "--keys", action="store_true",
            help="Print just the keys from omero.properties")
        parse_group.add_argument(
            "--headers", action="store_true",
            help="Print all headers from omero.properties")
        parse.add_argument(
            "--no-web", action="store_true",
            help="Do not parse Web properties")

        parser.add(sub, self.edit, "Present the properties for the current"
                   " profile in your editor. Saving them will update your"
                   " profile.")
        parser.add(sub, self.version, "Print the configuration version for"
                   " the current profile.")
        parser.add(sub, self.path, "Print the file that is used for "
                   " configuration")
        parser.add(sub, self.lock, "Acquire the config file lock and hold"
                   " it")
        parser.add(sub, self.upgrade, "Create a 4.2 config.xml file based on"
                   " your current Java Preferences")

    def open_config(self, args):
        if args.source:
            cfg_xml = path(args.source)
            if not cfg_xml.exists():
                self.ctx.die(124, "File not found: %s" % args.source)
        else:
            if 'OMERODIR' in os.environ:
                base_dir = path(os.environ.get('OMERODIR'))
            else:
                self.ctx.die(125, 'FATAL: OMERODIR env variable not set')
            grid_dir = base_dir / "etc" / "grid"
            if not grid_dir.exists():
                self.ctx.err("%s not found; creating %s" %
                             (grid_dir, grid_dir))
                os.makedirs(grid_dir)
            cfg_xml = grid_dir / "config.xml"
        try:
            return ConfigXml(str(cfg_xml))
        except portalocker.LockException:
            self.ctx.die(112, "Could not acquire lock on %s" % cfg_xml)
        except Exception as e:
            self.ctx.die(113, str(e))

    def assert_valid_property_name(self, key):
        from re import search

        if search(r'[^A-Za-z0-9._-]', key):
            self.ctx.die(506, 'Illegal property name: {0}'.format(key))

    @with_config
    def all(self, args, config):
        for k, v in config.properties(None, True):
            self.ctx.out(k)

    @with_config
    def default(self, args, config):
        if args.NAME is not None:
            self.die_on_ro(config)
        self.ctx.out(config.default(args.NAME))

    @with_config
    def drop(self, args, config):
        try:
            config.remove(args.NAME)
        except KeyError:
            self.ctx.err("Unknown configuration: %s" % args.NAME)

    @with_config
    def list(self, args, config):
        self.ctx.err('WARNING: "config list" is deprecated, '
                     'use "config get" instead')
        args.KEY = []
        self.get(args, config)

    @with_config
    def get(self, args, config):
        orig = sorted(list(config.keys()))
        keys = sorted(list(args.KEY))
        if not keys:
            keys = orig
            for k in config.IGNORE:
                k in keys and keys.remove(k)

        is_password = (lambda x: x.lower().endswith('pass') or
                       x.lower().endswith('password'))
        for k in keys:
            if k not in orig:
                continue
            if args.KEY and len(args.KEY) == 1:
                self.ctx.out(config[k])
            else:
                if is_password(k) and not getattr(
                        args, "show_password", False):
                    self.ctx.out("%s=%s" % (k, '*' * 8 if config[k] else ''))
                else:
                    self.ctx.out("%s=%s" % (k, config[k]))

    @with_rw_config
    def set(self, args, config):
        if "=" in args.KEY:
            k, v = args.KEY.split("=", 1)
            msg = """ "=" in key name. Did you mean "...set %s %s"?"""
            if args.VALUE is None:
                k, v = args.KEY.split("=", 1)
                self.ctx.err(msg % (k, v))
            elif args.KEY.endswith("="):
                self.ctx.err(msg % (k, args.VALUE))
        elif args.file:
            if args.file == "-":
                # Read from standard input
                import fileinput
                f = fileinput.input(args.file)
            else:
                f = args.file
            try:
                self.assert_valid_property_name(args.KEY)
                config[args.KEY] = (''.join(f)).rstrip()
            finally:
                f.close()
        elif args.VALUE is None:
            del config[args.KEY]
            if args.report:
                self.ctx.out('Changed: Removed %s' % args.KEY)
        else:
            self.assert_valid_property_name(args.KEY)
            config[args.KEY] = args.VALUE
            if args.report:
                self.ctx.out('Changed: Set %s:%s' % (args.KEY, args.VALUE))

    def get_list_value(self, args, config):
        import json
        try:
            list_value = json.loads(config[args.KEY])
        except ValueError:
            self.ctx.die(510, "No JSON object could be decoded")

        if not isinstance(list_value, list):
            self.ctx.die(511, "Property %s is not a list" % args.KEY)
        return list_value

    def get_omeroweb_default(self, key):
        try:
            from omeroweb import settings
            setting = settings.CUSTOM_SETTINGS_MAPPINGS.get(key)
            default = setting[2](setting[1]) if setting else []
        except Exception as e:
            self.ctx.dbg(traceback.format_exc())
            self.ctx.die(514,
                         "Cannot retrieve default value for property %s: %s" %
                         (key, e))
        if not isinstance(default, list):
            self.ctx.die(515, "Property %s is not a list" % key)
        return default

    @with_rw_config
    def append(self, args, config):
        import json
        if args.KEY in list(config.keys()):
            list_value = self.get_list_value(args, config)
        elif args.KEY.startswith('omero.web.'):
            list_value = self.get_omeroweb_default(args.KEY)
        else:
            list_value = []
        jv = json.loads(args.VALUE)
        if not args.set or jv not in list_value:
            list_value.append(json.loads(args.VALUE))
            config[args.KEY] = json.dumps(list_value)
            if args.report:
                self.ctx.out(
                    'Changed: Appended %s:%s' % (args.KEY, args.VALUE))

    @with_rw_config
    def remove(self, args, config):
        if args.KEY not in list(config.keys()):
            if args.KEY.startswith('omero.web.'):
                list_value = self.get_omeroweb_default(args.KEY)
            else:
                self.ctx.die(512, "Property %s is not defined" % (args.KEY))
        else:
            list_value = self.get_list_value(args, config)
        import json
        if json.loads(args.VALUE) not in list_value:
            self.ctx.die(513, "%s is not defined in %s"
                         % (args.VALUE, args.KEY))

        list_value.remove(json.loads(args.VALUE))
        config[args.KEY] = json.dumps(list_value)
        if args.report:
            self.ctx.out('Changed: Removed %s:%s' % (args.KEY, args.VALUE))

    @with_config
    def keys(self, args, config):
        for k in sorted(config.keys()):
            if k not in config.IGNORE:
                self.ctx.out(k)

    def parse(self, args):
        from omero.install.config_parser import PropertyParser
        pp = PropertyParser()
        if args.file:
            args.file.close()
            pp.parse_file(str(path(args.file.name).abspath()))
        else:
            pp.parse_lines(self.ctx.get_config_property_lines(self.dir))

        # Parse PSQL profile file
        for p in pp:
            if p.key == "omero.db.profile":
                psql_file = self.dir / "etc" / "profiles" / p.val
                pp.parse_file(str(psql_file.abspath()))
                break

        # Parse OMERO.web configuration properties
        if not args.no_web:
            pp.parse_module('omeroweb.settings')

        # Display options
        if args.headers:
            pp.print_headers()
        elif args.keys:
            pp.print_keys()
        elif args.rst:
            pp.print_rst()
        else:
            pp.print_defaults()

    @with_rw_config
    def load(self, args, config):
        keys = None
        if not args.q:
            keys = list(config.keys())

        # Handle all lines before updating config in case of error.
        new_config = dict(config)
        try:
            for f in args.file:
                if f == "-":
                    # Read from standard input
                    import fileinput
                    f = fileinput.input(f)

                try:
                    previous = None
                    for line in f:
                        if previous:
                            line = previous + line
                        previous = self.handle_line(line, new_config, keys)
                finally:
                    if f != "-":
                        f.close()
        except NonZeroReturnCode:
            raise
        except Exception as e:
            self.ctx.dbg(traceback.format_exc())
            self.ctx.die(968, "Cannot read %s: %s" % (args.file, e))
        for key, value in list(new_config.items()):
            config[key] = value

    @with_rw_config
    def edit(self, args, config, edit_path=edit_path):
        from omero.util.temp_files import create_path, remove_path
        start_text = "# Edit your preferences below. Comments are ignored\n"
        for k in sorted(config.keys()):
            start_text += ("%s=%s\n" % (k, config[k]))
        temp_file = create_path()
        try:
            edit_path(temp_file, start_text)
        except RuntimeError as re:
            self.ctx.dbg(traceback.format_exc())
            self.ctx.die(954, "%s: Failed to edit %s"
                         % (getattr(re, "pid", "Unknown"), temp_file))
        args.NAME = config.default()
        old_config = dict(config)
        self.drop(args, config)
        args.file = [open(str(temp_file), "r")]
        args.q = True
        try:
            self.load(args, config)
        except Exception as e:
            for key, value in list(old_config.items()):
                config[key] = value
            raise e
        finally:
            remove_path(temp_file)

    @with_config
    def version(self, args, config):
        self.ctx.out(config.version(config.default()))

    @with_config
    def path(self, args, config):
        self.ctx.out(config.filename)

    @with_rw_config
    def lock(self, args, config):
        self.ctx.input("Press enter to unlock")

    @with_rw_config
    def upgrade(self, args, config):
        self.ctx.out("Importing pre-4.2 preferences")
        txt = getprefs(["get"], str(old_div(self.ctx.dir, "lib")))

        # Handle all lines before updating config in case of error.
        new_config = dict(config)
        for line in txt.split("\n"):
            self.handle_line(line, new_config, None)
        for key, value in list(new_config.items()):
            config[key] = value

        # Upgrade procedure for 4.2
        MSG = """Manually modify them via "omero config old set ..." and \
re-run"""
        m = config.as_map()
        for x in ("keyStore", "keyStorePassword", "trustStore",
                  "trustStorePassword"):
            old = "omero.ldap." + x
            new = "omero.security." + x
            if old in m:
                config[new] = config[old]

        attributes, values = [], []
        if "omero.ldap.attributes" in m:
            attributes = config["omero.ldap.attributes"]
            attributes = attributes.split(",")
        if "omero.ldap.values" in m:
            values = config["omero.ldap.values"]
            values = values.split(",")

        if len(attributes) != len(values):
            raise ValueError("%s != %s\nLDAP properties in pre-4.2"
                             " configuration are invalid.\n%s"
                             % (attributes, values, MSG))
        pairs = list(zip(attributes, values))
        if pairs:
            if len(pairs) == 1:
                user_filter = "(%s=%s)" % (tuple(pairs[0]))
            else:
                user_filter = "(&%s)" % ["(%s=%s)" % tuple(pair)
                                         for pair in pairs]
            config["omero.ldap.user_filter"] = user_filter

        if "omero.ldap.groups" in m:
            raise ValueError("Not currently handling omero.ldap.groups\n%s"
                             % MSG)

        config["omero.config.upgraded"] = "4.2.0"

    def handle_line(self, line, config, keys):
        line = line.strip()
        if not line or line.startswith("#"):
            return None
        if line.endswith("\\"):
            return line[:-1]

        parts = line.split("=", 1)
        if len(parts[0]) == 0:
            return
        if len(parts) == 1:
            parts.append("")

        _key = parts[0]
        _new = parts[1]
        if _key in list(config.keys()):
            _old = config[_key]
        else:
            self.assert_valid_property_name(_key)
            _old = None

        if keys and _key in keys and _new != _old:

            self.ctx.die(502, "Duplicate property: %s ('%s' => '%s')"
                         % (_key, _old, _new))
            keys.append(_key)

        config[_key] = _new

    def old(self, args):
        self.ctx.out(getprefs(args.target, str(old_div(self.ctx.dir, "lib"))))

try:
    register("config", PrefsControl, HELP)
except NameError:
    if __name__ == "__main__":
        cli = CLI()
        cli.register("config", PrefsControl, HELP)
        cli.invoke(sys.argv[1:])
