import datetime
import logging
import os
import re
import json
import socket
import subprocess
import threading
import weakref
import six
import subprocess
from pathlib import Path

import pkg_resources

import requests

from chef.auth import sign_request
from chef.exceptions import ChefServerError
from chef.rsa import Key
import chef.utils.json
from chef.utils.file import walk_backwards
import chef.bin

api_stack = threading.local()
log = logging.getLogger("chef.api")

config_ruby_script = """
require 'chef'
Chef::Config.from_file('%s')
puts Chef::Config.configuration.to_json
""".strip()


def api_stack_value():
    if not hasattr(api_stack, "value"):
        api_stack.value = []
    return api_stack.value


class UnknownRubyExpression(Exception):
    """Token exception for unprocessed Ruby expressions."""


class ChefAPI(object):
    """The ChefAPI object is a wrapper for a single Chef server.

    .. admonition:: The API stack

        PyChef maintains a stack of :class:`ChefAPI` objects to be use with
        other methods if an API object isn't given explicitly. The first
        ChefAPI created will become the default, though you can set a specific
        default using :meth:`ChefAPI.set_default`. You can also use a ChefAPI
        as a context manager to create a scoped default::

            with ChefAPI('http://localhost:4000', 'client.pem', 'admin'):
                n = Node('web1')
    """

    ruby_value_re = re.compile(r"#\{([^}]+)\}")
    env_value_re = re.compile(r"ENV\[(.+)\]")
    ruby_string_re = re.compile(r'^\s*(["\'])(.*?)\1\s*$')

    def __init__(self, url, key, client, version="0.10.8", headers={}, ssl_verify=True):
        self.url = url.rstrip("/")
        self.parsed_url = six.moves.urllib.parse.urlparse(self.url)
        if not isinstance(key, Key):
            key = Key(key)
        if not key.key:
            raise ValueError("ChefAPI attribute 'key' was invalid.")
        self.key = key
        self.client = client
        self.version = version
        self.headers = dict((k.lower(), v) for k, v in six.iteritems(headers))
        self.version_parsed = pkg_resources.parse_version(self.version)
        self.platform = self.parsed_url.hostname == "api.opscode.com"
        self.ssl_verify = ssl_verify
        if not api_stack_value():
            self.set_default()

    @classmethod
    def from_config_file(cls, path):
        """Load Chef API paraters from a config file. Returns None if the
        config can't be used.
        """
        log.debug('Trying to load from "%s"', path)
        if not os.path.isfile(path) or not os.access(path, os.R_OK):
            # Can't even read the config file
            log.debug('Unable to read config file "%s"', path)
            return
        url = key_path = client_name = None
        ssl_verify = True
        for line in open(path):
            if not line.strip() or line.startswith("#"):
                continue  # Skip blanks and comments
            parts = line.split(None, 1)
            if len(parts) != 2:
                continue  # Not a simple key/value, we can't parse it anyway
            key, value = parts
            md = cls.ruby_string_re.search(value)
            if md:
                value = md.group(2)
            elif key == "ssl_verify_mode":
                log.debug("Found ssl_verify_mode: %r", value)
                ssl_verify = value.strip() != ":verify_none"
                log.debug("ssl_verify = %s", ssl_verify)
            else:
                # Not a string, don't even try
                log.debug(
                    "Value for {0} does not look like a string: {1}".format(key, value)
                )
                continue

            def _ruby_value(match):
                expr = match.group(1).strip()
                if expr == "current_dir":
                    return os.path.dirname(path)
                envmatch = cls.env_value_re.match(expr)
                if envmatch:
                    envmatch = envmatch.group(1).strip('"').strip("'")
                    return os.environ.get(envmatch) or ""
                log.debug('Unknown ruby expression in line "%s"', line)
                raise UnknownRubyExpression

            try:
                value = cls.ruby_value_re.sub(_ruby_value, value)
            except UnknownRubyExpression:
                continue
            if key == "chef_server_url":
                log.debug("Found URL: %r", value)
                url = value
            elif key == "node_name":
                log.debug("Found client name: %r", value)
                client_name = value
            elif key == "client_key":
                log.debug("Found key path: %r", value)
                key_path = value
                if not os.path.isabs(key_path):
                    # Relative paths are relative to the config file
                    key_path = os.path.abspath(
                        os.path.join(os.path.dirname(path), key_path)
                    )

        if not (url and client_name and key_path):
            # No URL, no chance this was valid, try running Ruby
            log.debug("No Chef server config found, trying Ruby parse")
            url = key_path = client_name = None
            proc = subprocess.Popen(
                "ruby", stdin=subprocess.PIPE, stdout=subprocess.PIPE
            )
            script = config_ruby_script % path.replace("\\", "\\\\").replace("'", "\\'")
            out, err = proc.communicate(script.encode())
            if proc.returncode == 0 and out.strip():
                data = chef.utils.json.loads(out.decode())
                log.debug("Ruby parse succeeded with %r", data)
                url = data.get("chef_server_url")
                client_name = data.get("node_name")
                key_path = data.get("client_key")
                if key_path and not os.path.isabs(key_path):
                    # Relative paths are relative to the config file
                    key_path = os.path.abspath(
                        os.path.join(os.path.dirname(path), key_path)
                    )
            else:
                log.debug(
                    "Ruby parse failed with exit code %s: %s",
                    proc.returncode,
                    out.strip(),
                )
        if not url:
            # Still no URL, can't use this config
            log.debug("Still no Chef server URL found")
            return
        if not key_path:
            # Try and use ./client.pem
            key_path = os.path.join(os.path.dirname(path), "client.pem")
        if not os.path.isfile(key_path) or not os.access(key_path, os.R_OK):
            # Can't read the client key
            log.debug('Unable to read key file "%s"', key_path)
            return
        if not client_name:
            client_name = socket.getfqdn()
        return cls(url, key_path, client_name, ssl_verify=ssl_verify)

    @staticmethod
    def get_global():
        """Return the API on the top of the stack."""
        while api_stack_value():
            api = api_stack_value()[-1]()
            if api is not None:
                return api
            del api_stack_value()[-1]

    def set_default(self):
        """Make this the default API in the stack. Returns the old default if any."""
        old = None
        if api_stack_value():
            old = api_stack_value().pop(0)
        api_stack_value().insert(0, weakref.ref(self))
        return old

    def __enter__(self):
        api_stack_value().append(weakref.ref(self))
        return self

    def __exit__(self, type, value, traceback):
        del api_stack_value()[-1]

    def _request(self, method, url, data, headers):
        return requests.api.request(
            method, url, headers=headers, data=data, verify=self.ssl_verify
        )

    def request(self, method, path, headers={}, data=None):
        auth_headers = sign_request(
            key=self.key,
            http_method=method,
            path=self.parsed_url.path + path.split("?", 1)[0],
            body=data,
            host=self.parsed_url.netloc,
            timestamp=datetime.datetime.utcnow(),
            user_id=self.client,
        )
        request_headers = {}
        request_headers.update(self.headers)
        request_headers.update(dict((k.lower(), v) for k, v in six.iteritems(headers)))
        request_headers["x-chef-version"] = self.version
        request_headers.update(auth_headers)
        try:
            response = self._request(
                method,
                self.url + path,
                data,
                dict((k.capitalize(), v) for k, v in six.iteritems(request_headers)),
            )
        except requests.ConnectionError as e:
            raise ChefServerError(e.message)

        if not response.ok:
            raise ChefServerError.from_error(response.reason, code=response.status_code)

        return response

    def api_request(self, method, path, headers={}, data=None):
        headers = dict((k.lower(), v) for k, v in six.iteritems(headers))
        headers["accept"] = "application/json"
        if data is not None:
            headers["content-type"] = "application/json"
            data = chef.utils.json.dumps(data)
        response = self.request(method, path, headers, data)
        return response.json()

    def __getitem__(self, path):
        return self.api_request("GET", path)


def autoconfigure_v2():
    """
    Re-uses Chef's configuration parsing & merging logic instead of reimplementing in Python :)
    See load_config_chef.utils.json.rb script for additional info

    Adds support for multiple configuration files from config.d directory.
    All files that end in .rb in the .d directory are loaded; other non-.rb files are ignored.

    For example:
    /etc/chef/config.d
    ~/.chef/config.d/company_settings.rb
    ~/.chef/config.d/ec2_configuration.rb
    ~/.chef/config.d/old_settings.rb.bak

    The old_settings.rb.bak file is ignored because it’s not a configuration file.
    The config.rb, company_settings.rb, and ec2_configuration files are merged together as if they are a single configuration file.

    https://docs.chef.io/workstation/config_rb/#d-directories
    """
    script = os.path.join(
        os.path.dirname(chef.bin.__file__), "load_config_chef.utils.json.rb"
    )
    stdout = subprocess.check_call(script)
    config = json.loads(stdout)

    chef_server_url = config.get("chef_server_url")
    key = config.get("client_key")
    ssl_verify_mode = config.get("ssl_verify_mode")
    node_name = config.get("node_name")

    ssl_verify = ssl_verify_mode != "verify_none"
    return ChefAPI(
        url=chef_server_url, key=key, client=node_name, ssl_verify=ssl_verify
    )


def autoconfigure(base_path=None):
    """Try to find a knife or chef-client config file to load parameters from,
    starting from either the given base path or the current working directory.

    The lookup order mirrors the one from Chef, first all folders from the base
    path are walked back looking for .chef/knife.rb, then ~/.chef/knife.rb,
    and finally /etc/chef/client.rb.

    The first file that is found and can be loaded successfully will be loaded
    into a :class:`ChefAPI` object.
    """
    log.warning(
        "DEPRECATION NOTICE: chef.api.autoconfigure will be removed in a future version of Py3Chef. Please use chef.api.autoconfigure_v2 to fix this warning."
    )
    base_path = base_path or os.getcwd()
    # Scan up the tree for a knife.rb or client.rb. If that fails try looking
    # in /etc/chef. The /etc/chef check will never work in Win32, but it doesn't
    # hurt either.
    for path in walk_backwards(base_path):
        config_path = os.path.join(path, ".chef", "knife.rb")
        api = ChefAPI.from_config_file(config_path)
        if api is not None:
            return api

    # The walk didn't work, try ~/.chef/knife.rb
    config_path = os.path.expanduser(os.path.join("~", ".chef", "knife.rb"))
    api = ChefAPI.from_config_file(config_path)
    if api is not None:
        return api

    # Nothing in the home dir, try /etc/chef/client.rb
    config_path = os.path.join(os.path.sep, "etc", "chef", "client.rb")
    api = ChefAPI.from_config_file(config_path)
    if api is not None:
        return api
