#!python
# -*- coding: utf-8 -*-
# Copyright 2019-2022 doowan
# SPDX-License-Identifier: GPL-3.0-or-later
"""
json-dotenv
"""

from __future__ import absolute_import

__version__ = '0.0.25'

import argparse
import json
import os
import re
import shutil
import sys
import tempfile
import warnings

from collections import OrderedDict

import logging
from logging.handlers import WatchedFileHandler

from six import (binary_type,
                 ensure_text,
                 iteritems,
                 PY2,
                 PY3,
                 StringIO,
                 string_types,
                 text_type)

import dotenv

warnings.filterwarnings('ignore')

SYSLOG_NAME          = "json-dotenv"
LOG                  = logging.getLogger(SYSLOG_NAME)

MATCH_VAR_NAME       = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*=').match
MATCH_VAR_NAME_QUOTE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*="').match
RE_CTRL_CHARS        = re.compile(r'([\x00-\x1f\x7f-\x9f]+)')
MATCH_PVAR_ESCAPE    = re.compile(r'\$\{[^\}]*\}')

ACTION_CHOICES       = ('list',
                        'keys',
                        'get',
                        'set',
                        'unset')

DEFAULT_LOGFILE      = "/var/log/json-dotenv/json-dotenv.log"

JSON_DOTENV_LOGFILE  = os.environ.get('JSON_DOTENV_LOGFILE') or DEFAULT_LOGFILE


RE_CRTL_CHARS  = re.compile(r'([\x00-\x1f\x7f-\x9f]+)')
RE_SPACE_CHARS = re.compile(r'\s\s+')
RE_YAML_QSTR = re.compile(r'^(?:\!\![a-z\/]+\s+)?\'(.*)\'$').match


def boolize(value):
    if isinstance(value, string_types):
        if value.lower() in ('y', 'yes', 't', 'true'):
            return True
        if not value.isdigit():
            return False
        value = int(value)

    return bool(value)

def argv_parse_check():
    """
    Parse (and check a little) command line parameters
    """
    parser          = argparse.ArgumentParser()

    parser.add_argument("command",
                        #default   = os.environ.get('JSON_DOTENV_COMMAND') or 'list',
                        choices   = ACTION_CHOICES,
                        nargs     = '?',
                        help      = "Commands: \n" + ", ".join(ACTION_CHOICES))
    parser.add_argument("--allow-envvar",
                        action    = 'store_true',
                        dest      = 'allow_envvar',
                        default   = boolize(os.environ.get('JSON_DOTENV_ALLOW_ENVVAR')) or False,
                        help      = "Allow environment variables expansion, instead of %(default)s")
    parser.add_argument("-c",
                        dest      = 'cmd',
                        default   = os.environ.get('JSON_DOTENV_COMMAND') or 'list',
                        choices   = ACTION_CHOICES,
                        help      = "Commands: \n" + ", ".join(ACTION_CHOICES) + ", instead of %(default)s (deprecated)")
    parser.add_argument("-k",
                        "--key",
                        action    = 'append',
                        dest      = 'key',
                        type      = ensure_text,
                        default   = [],
                        help      = "variable name to set or unset")
    parser.add_argument("-v",
                        "--value",
                        action    = 'append',
                        dest      = 'value',
                        type      = ensure_text,
                        default   = [],
                        help      = "variable value to set")
    parser.add_argument("-f",
                        dest      = 'file',
                        type      = ensure_text,
                        default   = os.environ.get('JSON_DOTENV_FILE') or os.path.join(os.getcwd(), '.env'),
                        help      = "Location of the environment file or from stdin (-), instead of %(default)s")
    parser.add_argument("--force",
                        action    = 'store_true',
                        dest      = 'force',
                        default   = False,
                        help      = "Force the output even if there is an error")
    parser.add_argument("-l",
                        "--loglevel",
                        dest      = 'loglevel',
                        default   = 'info',   # warning: see affectation under
                        choices   = ('critical', 'error', 'warning', 'info', 'debug'),
                        help      = ("Emit traces with LOGLEVEL details, must be one of:\t"
                                     "critical, error, warning, info, debug"))
    parser.add_argument("--logfile",
                        dest      = 'logfile',
                        type      = ensure_text,
                        default   = JSON_DOTENV_LOGFILE,
                        help      = "Use log file <logfile> instead of %(default)s")
    parser.add_argument("-o",
                        dest      = 'output',
                        type      = ensure_text,
                        default   = os.environ.get('JSON_DOTENV_OUTPUT') or '-',
                        help      = "Output result in file or to stdout")
    parser.add_argument("-q",
                        dest      = 'quote',
                        default   = os.environ.get('JSON_DOTENV_QUOTE') or 'always',
                        choices   = ('always', 'never', 'auto'),
                        help      = "Whether to quote or not the variable values, instead of %(default)s. This does not affect parsing")
    parser.add_argument("--format",
                        dest      = 'format',
                        default   = 'json',
                        choices   = ('env', 'json'),
                        help      = "Output format env or json, instead of %(default)s")

    options, args   = parser.parse_known_args()

    if options.cmd and not options.command:
        options.command = options.cmd

    if args:
        parser.error("no argument is allowed - use option --help to get an help screen")

    options.loglevel = getattr(logging, options.loglevel.upper(), logging.INFO)

    return options


class JsonDotEnvExit(SystemExit):
    pass

class JsonDotEnv(object): # pylint: disable=bad-option-value,useless-object-inheritance
    def __init__(self, options):
        self.options   = options
        self.fcontent  = None
        self.variables = {}

    @staticmethod
    def flush_sync_file_object(fo):
        """
        Flush internal buffers of @fo, then ask the OS to flush its own buffers.
        """
        fo.flush()
        os.fsync(fo.fileno())

    def file_w_tmp(self, lines, path = None, mode = 'w+'):
        with tempfile.NamedTemporaryFile(mode, delete = False) as f:
            LOG.debug("writing temporary file: %r", f.name)
            f.writelines(lines)
            self.flush_sync_file_object(f)

        if not path:
            return f.name

        if os.path.isfile(path):
            LOG.debug("overwriting file: %r", path)
            stat = os.stat(path)
            os.chmod(f.name, stat.st_mode)
            os.chown(f.name, stat.st_uid, stat.st_gid)
        LOG.debug("move file: %r to %r", f.name, path)
        shutil.move(f.name, path)

        return path

    @staticmethod
    def raw_string(value):
        def repl_ctrl_chars(match):
            s = match.group()
            if PY2 and isinstance(s, str):
                return s.encode('string-escape')
            if isinstance(s, text_type):
                r = s.encode('unicode-escape')
                if PY3 and isinstance(r, binary_type):
                    return ensure_text(r)
                return r

            return repr(s)[1:-1]

        return RE_CTRL_CHARS.sub(repl_ctrl_chars, value)

    def escape_variable(self, value):
        if self.options.allow_envvar:
            return value

        def repl_esc_variable(match):
            varesc = match.group().replace('${', r'\$\{').replace('}', r'\}')
            self.variables[varesc] = match.group()

            return ensure_text(varesc)

        return MATCH_PVAR_ESCAPE.sub(repl_esc_variable, value)

    def unescape_variables(self, value):
        if self.options.allow_envvar:
            return value

        if not self.variables:
            return value

        for key, item in iteritems(self.variables):
            value = value.replace(key, item)

        return value

    def _format(self, out):
        if self.options.format == 'json':
            if isinstance(out, dict):
                for k, v in iteritems(out):
                    out[k] = self.unescape_variables(v)
            return json.dumps(out)

        r = []

        if isinstance(out, dict):
            for k, v in iteritems(out):
                if self.options.quote == 'always':
                    f = '{}="{}"'
                elif self.options.quote == 'auto' \
                   and (v.find(' ') > -1 or RE_CTRL_CHARS.match(v)):
                    f = '{}="{}"'
                else:
                    f = '{}={}'
                r.append(f.format(k, self.unescape_variables(v)))
        elif isinstance(out, (list, tuple)):
            r.extend(out)
        else:
            r = "%s" % out

        return "\n".join(r)

    def _parse_file(self):
        if self.options.file == '-':
            xfile = StringIO(sys.stdin.read())
        elif not self.options.file:
            xfile = StringIO()
        elif not os.path.exists(self.options.file):
            raise IOError("unable to find file: %r" % self.options.file)
        else:
            xfile = open(self.options.file, 'r')

        content = []
        cur     = ""
        quoted  = None

        for x in xfile.readlines():
            if MATCH_VAR_NAME_QUOTE(x):
                if cur:
                    if quoted is False:
                        cur = cur.rstrip("\\n")
                        cur += '"'
                    content.append(self.escape_variable(cur.rstrip("\\n")))
                quoted = True
                cur    = self.raw_string(x)
            elif MATCH_VAR_NAME(x):
                if cur:
                    if quoted is False:
                        cur = cur.rstrip("\\n")
                        cur += '"'
                    content.append(self.escape_variable(cur.rstrip("\\n")))
                quoted = False
                cur    = '{0}="{1}'.format(*self.raw_string(x).split('=', 1))
            else:
                cur += self.raw_string(x)

        if cur:
            if quoted is False:
                cur = cur.rstrip("\\n")
                cur += '"'
            content.append(self.escape_variable(cur.rstrip("\\n")))

        if xfile:
            xfile.close()

        self.fcontent = StringIO("\n".join(content) + "\n")

    def _valid_varname(self, key):
        if not key:
            LOG.error("missing variable name for command %s", self.options.command)
            raise JsonDotEnvExit(1)
        if not isinstance(key, string_types) \
             or not MATCH_VAR_NAME("%s=" % key):
            LOG.error("invalid variable name for command %s", self.options.command)
            raise JsonDotEnvExit(2)

        return True

    def _valid_varvalue(self, value):
        if value is None:
            LOG.error("missing variable value for command %s", self.options.command)
            raise JsonDotEnvExit(3)

        return True

    def _output(self, content):
        if self.options.output == '-':
            sys.stdout.write(content + "\n")
        else:
            self.file_w_tmp(content + "\n", self.options.output, mode = 'w+')

    def do_list(self):
        self._parse_file()

        return self._output(
            self._format(
                dotenv.dotenv_values(self.fcontent)))

    def do_keys(self):
        self._parse_file()

        return self._output(
            self._format(
                list(dotenv.dotenv_values(self.fcontent))))

    def do_get(self):
        if not self.options.key:
            LOG.error("missing argument key")
            raise JsonDotEnvExit(4)

        r     = OrderedDict()

        self._parse_file()

        envs  = dotenv.dotenv_values(self.fcontent)

        for key in self.options.key:
            self._valid_varname(key)

            if key in envs:
                r[key] = envs[key]
                continue

            LOG.warning("unable to get key: %r", key)
            if not self.options.force:
                raise JsonDotEnvExit(4)

        return self._output(self._format(r))

    def do_set(self):
        if not self.options.key:
            LOG.error("missing argument key")
            raise JsonDotEnvExit(4)

        if not self.options.value or len(self.options.key) != len(self.options.key):
            LOG.error("missing argument value")
            raise JsonDotEnvExit(4)

        fpath = None
        self._parse_file()

        try:
            fpath = self.file_w_tmp(self.fcontent.getvalue())

            for i, key in enumerate(self.options.key):
                value = self.options.value[i]
                self._valid_varname(key)
                self._valid_varvalue(value)
                #pylint: disable=unused-variable
                success, k, v = dotenv.set_key(fpath,
                                               key,
                                               value,
                                               self.options.quote)

                if success:
                    continue

                LOG.warning("unable to set key: %r", key)
                if not self.options.force:
                    raise JsonDotEnvExit(4)

            return self._output(
                self._format(
                    dotenv.dotenv_values(fpath)))
        finally:
            if fpath and os.path.isfile(fpath):
                os.unlink(fpath)

    def do_unset(self):
        if not self.options.key:
            LOG.error("missing argument key")
            raise JsonDotEnvExit(4)

        fpath = None
        self._parse_file()

        try:
            fpath = self.file_w_tmp(self.fcontent.getvalue())

            for key in self.options.key:
                self._valid_varname(key)
                success, k = dotenv.unset_key(fpath,
                                              key,
                                              self.options.quote)
                if success:
                    continue

                LOG.warning("unable to remove key: %r", k)
                if not self.options.force:
                    raise JsonDotEnvExit(4)

            return self._output(
                self._format(
                    dotenv.dotenv_values(fpath)))
        finally:
            if fpath and os.path.isfile(fpath):
                os.unlink(fpath)


def main(options):
    """
    Main function
    """

    xformat = "%(levelname)s:%(asctime)-15s: %(message)s"
    datefmt = '%Y-%m-%d %H:%M:%S'
    logging.basicConfig(level   = options.loglevel,
                        format  = xformat,
                        datefmt = datefmt)

    logdir  = os.path.dirname(options.logfile)
    if os.path.isdir(logdir) and os.access(logdir, os.W_OK):
        filehandler = WatchedFileHandler(options.logfile)
        filehandler.setFormatter(logging.Formatter(xformat,
                                                   datefmt = datefmt))
        root_logger = logging.getLogger('')
        root_logger.addHandler(filehandler)

    rc = 0

    try:
        getattr(JsonDotEnv(options), "do_%s" % options.command)()
    except JsonDotEnvExit as e:
        rc = e.code
    except (SystemExit, KeyboardInterrupt):
        rc = 255
    except IOError as e:
        rc = 5
        LOG.error(e)
    except Exception as e:
        rc = 6
        LOG.exception(e)

    return rc


if __name__ == '__main__':
    sys.exit(main(argv_parse_check()))
