#!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.1.0'

import argparse
import json
import os
import re
import sys
import tempfile

from collections import OrderedDict

import logging
from logging.handlers import WatchedFileHandler

from io import StringIO

ensure_text = str
string_types = (str,)

import dotenv



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

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


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      = "Ignore missing keys for get and unset")
    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):
    def __init__(self, options):
        self.options = options

    def _read(self):
        if self.options.file == '-':
            content = sys.stdin.read()
        elif not self.options.file:
            content = ''
        else:
            with open(self.options.file, encoding='utf-8') as stream:
                content = stream.read()
        # Validate before parsing: python-dotenv otherwise skips malformed lines.
        from dotenv.parser import parse_stream
        for binding in parse_stream(StringIO(content)):
            if binding.error:
                raise ValueError('Invalid dotenv statement at line %s' % binding.original.line)
            if binding.key is not None:
                self._valid_varname(binding.key)
        return dotenv.dotenv_values(stream=StringIO(content),
                                    interpolate=self.options.allow_envvar)

    def _valid_varname(self, key):
        if not isinstance(key, str) or re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', key) is None:
            raise ValueError('Invalid variable name: %r' % key)

    @staticmethod
    def _quote(value):
        # Double-quoted dotenv syntax; escape backslashes before other characters.
        return '\"' + value.replace('\\', '\\\\').replace('\"', '\\\"').replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t') + '\"'

    def _format(self, values):
        if self.options.format == 'json':
            return json.dumps(values, ensure_ascii=False)
        if isinstance(values, list):
            return '\n'.join(values)
        lines = []
        for key, value in values.items():
            if value is None:
                lines.append(key)
                continue
            safe = re.fullmatch(r'[A-Za-z0-9_./:@%+,=-]*', value) is not None
            if self.options.quote == 'never' and not safe:
                raise ValueError('Value for %s requires quoting; use -q auto or always' % key)
            quoted = self.options.quote == 'always' or (self.options.quote == 'auto' and not safe)
            lines.append('%s=%s' % (key, self._quote(value) if quoted else value))
        return '\n'.join(lines)

    def _output(self, content):
        if self.options.output == '-':
            sys.stdout.write(content + '\n')
            return
        path = os.path.abspath(self.options.output)
        if os.path.islink(path):
            raise ValueError('Refusing to replace a symbolic link')
        tmp = None
        try:
            with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8',
                                             dir=os.path.dirname(path), delete=False) as stream:
                tmp = stream.name
                if os.path.exists(path):
                    stat = os.stat(path)
                    current = os.fstat(stream.fileno())
                    if (current.st_uid, current.st_gid) != (stat.st_uid, stat.st_gid):
                        os.fchown(stream.fileno(), stat.st_uid, stat.st_gid)
                    os.fchmod(stream.fileno(), stat.st_mode & 0o777)
                stream.write(content + '\n')
                stream.flush()
                os.fsync(stream.fileno())
            os.replace(tmp, path)
        finally:
            if tmp and os.path.exists(tmp):
                os.unlink(tmp)

    def do_list(self):
        self._output(self._format(self._read()))

    def do_keys(self):
        self._output(self._format(list(self._read())))

    def _keys(self):
        if not self.options.key:
            raise ValueError('At least one -k/--key is required')
        for key in self.options.key:
            self._valid_varname(key)
        return self.options.key

    def do_get(self):
        keys = self._keys()
        values = self._read()
        result = OrderedDict()
        for key in keys:
            if key not in values:
                if not self.options.force:
                    raise ValueError('Key not found: %s' % key)
            else:
                result[key] = values[key]
        self._output(self._format(result))

    def do_set(self):
        keys = self._keys()
        if len(keys) != len(self.options.value):
            raise ValueError('Provide exactly one -v/--value per key')
        values = self._read()
        for key, value in zip(keys, self.options.value):
            if self.options.allow_envvar:
                from dotenv.variables import parse_variables
                context = dict(os.environ)
                context.update(values)
                value = ''.join(atom.resolve(context) for atom in parse_variables(value))
            values[key] = value
        self._output(self._format(values))

    def do_unset(self):
        keys = self._keys()
        values = self._read()
        for key in keys:
            if key not in values and not self.options.force:
                raise ValueError('Key not found: %s' % key)
            values.pop(key, None)
        self._output(self._format(values))


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 ValueError as e:
        rc = 4
        LOG.error(e)
    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()))
