#!/usr/bin/env python
# -- Content-Encoding: UTF-8 --
"""
Pelix shell service bundle.

Provides the basic command parsing and execution support to make a Pelix shell.

:author: Thomas Calmant
:copyright: Copyright 2016, Thomas Calmant
:license: Apache License 2.0
:version: 0.6.4

..

    Copyright 2016 Thomas Calmant

    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
    You may obtain a copy of the License at

        http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing, software
    distributed under the License is distributed on an "AS IS" BASIS,
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    See the License for the specific language governing permissions and
    limitations under the License.
"""

# Standard library
import logging
import os
import sys
import threading

# Pelix modules
import pelix.constants as constants
import pelix.framework as pelix

# Shell constants
from pelix.shell import SERVICE_SHELL, SERVICE_SHELL_COMMAND, \
    SERVICE_SHELL_UTILS
from pelix.shell.report import format_frame_info
import pelix.shell.parser as parser

# ------------------------------------------------------------------------------

# Public API
__all__ = ()

# Module version
__version_info__ = (0, 6, 4)
__version__ = ".".join(str(x) for x in __version_info__)

# Documentation strings format
__docformat__ = "restructuredtext en"

# ------------------------------------------------------------------------------


class _ShellUtils(object):
    """
    Utility methods for the shell
    """
    @staticmethod
    def bundlestate_to_str(state):
        """
        Converts a bundle state integer to a string
        """
        states = {
            pelix.Bundle.INSTALLED: "INSTALLED",
            pelix.Bundle.ACTIVE: "ACTIVE",
            pelix.Bundle.RESOLVED: "RESOLVED",
            pelix.Bundle.STARTING: "STARTING",
            pelix.Bundle.STOPPING: "STOPPING",
            pelix.Bundle.UNINSTALLED: "UNINSTALLED"
        }

        return states.get(state, "Unknown state ({0})".format(state))

    @staticmethod
    def make_table(headers, lines, prefix=None):
        """
        Generates an ASCII table according to the given headers and lines

        :param headers: List of table headers (N-tuple)
        :param lines: List of table lines (N-tuples)
        :param prefix: Optional prefix for each line
        :return: The ASCII representation of the table
        :raise ValueError: Different number of columns between headers and
                           lines
        """
        # Normalize the prefix
        prefix = str(prefix or "")

        # Maximum lengths
        lengths = [len(title) for title in headers]

        # Store the number of columns (0-based)
        nb_columns = len(lengths) - 1

        # Lines
        str_lines = []
        for idx, line in enumerate(lines):
            # Recompute lengths
            str_line = []
            str_lines.append(str_line)
            column = -1

            try:
                for column, entry in enumerate(line):
                    str_entry = str(entry)
                    str_line.append(str_entry)

                    if len(str_entry) > lengths[column]:
                        lengths[column] = len(str_entry)

            except IndexError:
                # Line too small/big
                raise ValueError("Different sizes for header and lines "
                                 "(line {0})".format(idx + 1))

            except (TypeError, AttributeError):
                # Invalid type of line
                raise ValueError("Invalid type of line: %s",
                                 type(line).__name__)

            else:
                if column != nb_columns:
                    # Check if all lines have the same number of columns
                    raise ValueError("Different sizes for header and lines "
                                     "(line {0})".format(idx + 1))

        # Prepare the head (centered text)
        format_str = "{0}|".format(prefix)
        for column, length in enumerate(lengths):
            format_str += " {%d:^%d} |" % (column, length)

        head_str = format_str.format(*headers)

        # Prepare the separator, according the length of the headers string
        separator = '{0}{1}'.format(prefix,
                                    '-' * (len(head_str) - len(prefix)))
        idx = head_str.find('|')
        while idx != -1:
            separator = '+'.join((separator[:idx], separator[idx + 1:]))
            idx = head_str.find('|', idx + 1)

        # Prepare the output
        output = [separator,
                  head_str,
                  separator.replace('-', '=')]

        # Compute the lines
        format_str = format_str.replace('^', '<')
        for line in str_lines:
            output.append(format_str.format(*line))
            output.append(separator)

        # Force the last end of line
        output.append("")

        # Join'em
        return '\n'.join(output)


# ------------------------------------------------------------------------------


class _ShellService(parser.Shell):
    """
    Provides the core shell service for Pelix
    """
    def __init__(self, context, utilities):
        """
        Sets up the shell

        :param context: The bundle context
        """
        super(_ShellService, self).__init__(context.get_bundle(0), __name__)
        self._context = context
        self._utils = utilities

        # Bound services: reference -> service
        self._bound_references = {}

        # Service reference -> (name space, [commands])
        self._reference_commands = {}

        # Last working directory
        self._previous_path = None

        # Register basic commands
        self.register_command(None, "bd", self.bundle_details)
        self.register_command(None, "bl", self.bundles_list)

        self.register_command(None, "sd", self.service_details)
        self.register_command(None, "sl", self.services_list)

        self.register_command(None, "start", self.start)
        self.register_command(None, "stop", self.stop)
        self.register_command(None, "update", self.update)
        self.register_command(None, "install", self.install)
        self.register_command(None, "uninstall", self.uninstall)

        self.register_command(None, "properties", self.properties_list)
        self.register_command(None, "property", self.property_value)

        self.register_command(None, "sysprops", self.environment_list)
        self.register_command(None, "sysprop", self.environment_value)

        self.register_command(None, "threads", self.threads_list)
        self.register_command(None, "thread", self.thread_details)

        self.register_command(None, "loglevel", self.log_level)

        self.register_command(None, "cd", self.change_dir)
        self.register_command(None, "pwd", self.print_dir)

    def bind_handler(self, svc_ref):
        """
        Called if a command service has been found.
        Registers the methods of this service.

        :param svc_ref: A reference to the found service
        :return: True if the commands have been registered
        """
        if svc_ref in self._bound_references:
            # Already bound service
            return False

        # Get the service
        handler = self._context.get_service(svc_ref)

        # Get its name space
        namespace = handler.get_namespace()
        commands = []

        # Register all service methods directly
        for command, method in handler.get_methods():
            self.register_command(namespace, command, method)
            commands.append(command)

        # Store the reference
        self._bound_references[svc_ref] = handler
        self._reference_commands[svc_ref] = (namespace, commands)
        return True

    def unbind_handler(self, svc_ref):
        """
        Called if a command service is gone.
        Unregisters its commands.

        :param svc_ref: A reference to the unbound service
        :return: True if the commands have been unregistered
        """
        if svc_ref not in self._bound_references:
            # Unknown reference
            return False

        # Unregister its commands
        namespace, commands = self._reference_commands[svc_ref]
        for command in commands:
            self.unregister(namespace, command)

        # Release the service
        self._context.unget_service(svc_ref)
        del self._bound_references[svc_ref]
        del self._reference_commands[svc_ref]
        return True

    @staticmethod
    def get_banner():
        """
        Returns the Shell banner
        """
        return "** Pelix Shell prompt **\n"

    def var_set(self, session, **kwargs):
        """
        Sets the given variables or prints the current ones. "set answer=42"
        """
        if not kwargs:
            session.write_line(
                self._utils.make_table(('Name', 'Value'),
                                       session.variables.items()))
        else:
            for name, value in kwargs.items():
                name = name.strip()
                session.set(name, value)
                session.write_line("{0}={1}", name, value)

    def bundle_details(self, io_handler, bundle_id):
        """
        Prints the details of the bundle with the given ID or name
        """
        bundle = None

        try:
            # Convert the given ID into an integer
            bundle_id = int(bundle_id)
        except ValueError:
            # Not an integer, suppose it's a bundle name
            for bundle in self._context.get_bundles():
                if bundle.get_symbolic_name() == bundle_id:
                    break
            else:
                # Bundle not found
                bundle = None
        else:
            # Integer ID: direct access
            try:
                bundle = self._context.get_bundle(bundle_id)
            except constants.BundleException:
                pass

        if bundle is None:
            # No matching bundle
            io_handler.write_line("Unknown bundle ID: {0}", bundle_id)
            return False

        lines = ["ID......: {0}".format(bundle.get_bundle_id()),
                 "Name....: {0}".format(bundle.get_symbolic_name()),
                 "Version.: {0}".format(bundle.get_version()),
                 "State...: {0}".format(
                     self._utils.bundlestate_to_str(bundle.get_state())),
                 "Location: {0}".format(bundle.get_location()),
                 "Published services:"]
        try:
            services = bundle.get_registered_services()
            if services:
                for svc_ref in services:
                    lines.append("\t{0}".format(svc_ref))
            else:
                lines.append("\tn/a")
        except constants.BundleException as ex:
            # Bundle in a invalid state
            lines.append("\tError: {0}".format(ex))

        lines.append("Services used by this bundle:")
        try:
            services = bundle.get_services_in_use()
            if services:
                for svc_ref in services:
                    lines.append("\t{0}".format(svc_ref))
            else:
                lines.append("\tn/a")
        except constants.BundleException as ex:
            # Bundle in a invalid state
            lines.append("\tError: {0}".format(ex))

        lines.append("")
        io_handler.write('\n'.join(lines))

    def bundles_list(self, io_handler, name=None):
        """
        Lists the bundles in the framework and their state. Possibility to
        filter on the bundle name.
        """
        # Head of the table
        headers = ('ID', 'Name', 'State', 'Version')

        # Get the bundles
        bundles = self._context.get_bundles()

        # The framework is not in the result of get_bundles()
        bundles.insert(0, self._context.get_bundle(0))

        if name is not None:
            # Filter the list
            bundles = [bundle for bundle in bundles
                       if name in bundle.get_symbolic_name()]

        # Make the entries
        lines = [[str(entry)
                  for entry in (bundle.get_bundle_id(),
                                bundle.get_symbolic_name(),
                                self._utils.bundlestate_to_str(
                                    bundle.get_state()),
                                bundle.get_version())]
                 for bundle in bundles]

        # Print'em all
        io_handler.write(self._utils.make_table(headers, lines))

        if name is None:
            io_handler.write_line("{0} bundles installed", len(lines))
        else:
            io_handler.write_line("{0} filtered bundles", len(lines))

    def service_details(self, io_handler, service_id):
        """
        Prints the details of the service with the given ID
        """
        svc_ref = self._context.get_service_reference(
            None, '({0}={1})'.format(constants.SERVICE_ID, service_id))
        if svc_ref is None:
            io_handler.write_line('Service not found: {0}', service_id)
            return False

        lines = [
            "ID............: {0}".format(
                svc_ref.get_property(constants.SERVICE_ID)),
            "Rank..........: {0}".format(
                svc_ref.get_property(constants.SERVICE_RANKING)),
            "Specifications: {0}".format(
                svc_ref.get_property(constants.OBJECTCLASS)),
            "Bundle........: {0}".format(svc_ref.get_bundle()),
            "Properties....:"]
        for key, value in sorted(svc_ref.get_properties().items()):
            lines.append("\t{0} = {1}".format(key, value))

        lines.append("Bundles using this service:")
        for bundle in svc_ref.get_using_bundles():
            lines.append("\t{0}".format(bundle))

        lines.append("")
        io_handler.write('\n'.join(lines))

    def services_list(self, io_handler, specification=None):
        """
        Lists the services in the framework. Possibility to filter on an exact
        specification.
        """
        # Head of the table
        headers = ('ID', 'Specifications', 'Bundle', 'Ranking')

        # Lines
        references = self._context.get_all_service_references(
            specification, None) or []

        # Construct the list of services
        lines = [[str(entry)
                  for entry in (ref.get_property(constants.SERVICE_ID),
                                ref.get_property(constants.OBJECTCLASS),
                                ref.get_bundle(),
                                ref.get_property(constants.SERVICE_RANKING))]
                 for ref in references]

        if not lines and specification:
            # No matching service found
            io_handler.write_line("No service provides '{0}'", specification)
            return False
        else:
            # Print'em all
            io_handler.write(self._utils.make_table(headers, lines))
            io_handler.write_line("{0} services registered", len(lines))

    def properties_list(self, io_handler):
        """
        Lists the properties of the framework
        """
        # Get the framework
        framework = self._context.get_bundle(0)

        # Head of the table
        headers = ('Property Name', 'Value')

        # Lines
        lines = [item for item in framework.get_properties().items()]

        # Sort lines
        lines.sort()

        # Print the table
        io_handler.write(self._utils.make_table(headers, lines))

    def property_value(self, io_handler, name):
        """
        Prints the value of the given property, looking into
        framework properties then environment variables.
        """
        value = self._context.get_property(name)
        if value is None:
            # Avoid printing "None"
            value = ""

        io_handler.write_line(str(value))

    def environment_list(self, io_handler):
        """
        Lists the framework process environment variables
        """
        # Head of the table
        headers = ('Environment Variable', 'Value')

        # Lines
        lines = [item for item in os.environ.items()]

        # Sort lines
        lines.sort()

        # Print the table
        io_handler.write(self._utils.make_table(headers, lines))

    @staticmethod
    def environment_value(io_handler, name):
        """
        Prints the value of the given environment variable
        """
        io_handler.write_line(os.getenv(name))

    @staticmethod
    def threads_list(io_handler, max_depth=1):
        """
        Lists the active threads and their current code line
        """
        # Normalize maximum depth
        try:
            max_depth = int(max_depth)
            if max_depth < 1:
                max_depth = None
        except (ValueError, TypeError):
            max_depth = None

        # pylint: disable=W0212
        try:
            # Extract frames
            frames = sys._current_frames()

            # Get the thread ID -> Thread mapping
            names = threading._active.copy()
        except AttributeError:
            io_handler.write_line("sys._current_frames() is not available.")
            return

        # Sort by thread ID
        thread_ids = sorted(frames.keys())
        lines = []
        for thread_id in thread_ids:
            # Get the corresponding stack
            stack = frames[thread_id]

            # Try to get the thread name
            try:
                name = names[thread_id].name
            except KeyError:
                name = "<unknown>"

            # Construct the code position
            lines.append('Thread ID: {0} - Name: {1}'.format(thread_id, name))
            lines.append('Stack Trace:')

            trace_lines = []
            depth = 0
            frame = stack
            while frame is not None \
                    and (max_depth is None or depth < max_depth):
                # Store the line information
                trace_lines.append(format_frame_info(frame))

                # Previous frame...
                frame = frame.f_back
                depth += 1

            # Reverse the lines
            trace_lines.reverse()

            # Add them to the printed lines
            lines.extend(trace_lines)
            lines.append('')

        lines.append('')