"""Provides methods to bootstrap a home assistant instance."""

import logging
import logging.handlers
import os
import shutil
import sys
from collections import defaultdict
from threading import RLock

import voluptuous as vol

import homeassistant.components as core_components
import homeassistant.components.group as group
import homeassistant.config as config_util
import homeassistant.core as core
import homeassistant.helpers.config_validation as cv
import homeassistant.loader as loader
import homeassistant.util.dt as date_util
import homeassistant.util.location as loc_util
import homeassistant.util.package as pkg_util
from homeassistant.const import (
    CONF_CUSTOMIZE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME,
    CONF_TEMPERATURE_UNIT, CONF_TIME_ZONE, EVENT_COMPONENT_LOADED,
    TEMP_CELSIUS, TEMP_FAHRENHEIT, PLATFORM_FORMAT, __version__)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
    event_decorators, service, config_per_platform, extract_domain_configs)
from homeassistant.helpers.entity import Entity

_LOGGER = logging.getLogger(__name__)
_SETUP_LOCK = RLock()
_CURRENT_SETUP = []

ATTR_COMPONENT = 'component'

ERROR_LOG_FILENAME = 'home-assistant.log'


def setup_component(hass, domain, config=None):
    """Setup a component and all its dependencies."""
    if domain in hass.config.components:
        return True

    _ensure_loader_prepared(hass)

    if config is None:
        config = defaultdict(dict)

    components = loader.load_order_component(domain)

    # OrderedSet is empty if component or dependencies could not be resolved
    if not components:
        return False

    for component in components:
        if not _setup_component(hass, component, config):
            return False

    return True


def _handle_requirements(hass, component, name):
    """Install the requirements for a component."""
    if hass.config.skip_pip or not hasattr(component, 'REQUIREMENTS'):
        return True

    for req in component.REQUIREMENTS:
        if not pkg_util.install_package(req, target=hass.config.path('deps')):
            _LOGGER.error('Not initializing %s because could not install '
                          'dependency %s', name, req)
            return False

    return True


def _setup_component(hass, domain, config):
    """Setup a component for Home Assistant."""
    # pylint: disable=too-many-return-statements,too-many-branches
    if domain in hass.config.components:
        return True

    with _SETUP_LOCK:
        # It might have been loaded while waiting for lock
        if domain in hass.config.components:
            return True

        if domain in _CURRENT_SETUP:
            _LOGGER.error('Attempt made to setup %s during setup of %s',
                          domain, domain)
            return False

        component = loader.get_component(domain)
        missing_deps = [dep for dep in getattr(component, 'DEPENDENCIES', [])
                        if dep not in hass.config.components]

        if missing_deps:
            _LOGGER.error(
                'Not initializing %s because not all dependencies loaded: %s',
                domain, ", ".join(missing_deps))
            return False

        if hasattr(component, 'CONFIG_SCHEMA'):
            try:
                config = component.CONFIG_SCHEMA(config)
            except vol.MultipleInvalid as ex:
                cv.log_exception(_LOGGER, ex, domain, config)
                return False

        elif hasattr(component, 'PLATFORM_SCHEMA'):
            platforms = []
            for p_name, p_config in config_per_platform(config, domain):
                # Validate component specific platform schema
                try:
                    p_validated = component.PLATFORM_SCHEMA(p_config)
                except vol.MultipleInvalid as ex:
                    cv.log_exception(_LOGGER, ex, domain, p_config)
                    return False

                # Not all platform components follow same pattern for platforms
                # So if p_name is None we are not going to validate platform
                # (the automation component is one of them)
                if p_name is None:
                    platforms.append(p_validated)
                    continue

                platform = prepare_setup_platform(hass, config, domain,
                                                  p_name)

                if platform is None:
                    return False

                # Validate platform specific schema
                if hasattr(platform, 'PLATFORM_SCHEMA'):
                    try:
                        p_validated = platform.PLATFORM_SCHEMA(p_validated)
                    except vol.MultipleInvalid as ex:
                        cv.log_exception(_LOGGER, ex, '{}.{}'
                                         .format(domain, p_name), p_validated)
                        return False

                platforms.append(p_validated)

            # Create a copy of the configuration with all config for current
            # component removed and add validated config back in.
            filter_keys = extract_domain_configs(config, domain)
            config = {key: value for key, value in config.items()
                      if key not in filter_keys}
            config[domain] = platforms

        if not _handle_requirements(hass, component, domain):
            return False

        _CURRENT_SETUP.append(domain)

        try:
            if not component.setup(hass, config):
                _LOGGER.error('component %s failed to initialize', domain)
                return False
        except Exception:  # pylint: disable=broad-except
            _LOGGER.exception('Error during setup of component %s', domain)
            return False
        finally:
            _CURRENT_SETUP.remove(domain)

        hass.config.components.append(component.DOMAIN)

        # Assumption: if a component does not depend on groups
        # it communicates with devices
        if group.DOMAIN not in getattr(component, 'DEPENDENCIES', []):
            hass.pool.add_worker()

        hass.bus.fire(
            EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN})

        return True


def prepare_setup_platform(hass, config, domain, platform_name):
    """Load a platform and makes sure dependencies are setup."""
    _ensure_loader_prepared(hass)

    platform_path = PLATFORM_FORMAT.format(domain, platform_name)

    platform = loader.get_platform(domain, platform_name)

    # Not found
    if platform is None:
        _LOGGER.error('Unable to find platform %s', platform_path)
        return None

    # Already loaded
    elif platform_path in hass.config.components:
        return platform

    # Load dependencies
    for component in getattr(platform, 'DEPENDENCIES', []):
        if not setup_component(hass, component, config):
            _LOGGER.error(
                'Unable to prepare setup for platform %s because '
                'dependency %s could not be initialized', platform_path,
                component)
            return None

    if not _handle_requirements(hass, platform, platform_path):
        return None

    return platform


def mount_local_lib_path(config_dir):
    """Add local library to Python Path."""
    sys.path.insert(0, os.path.join(config_dir, 'deps'))


# pylint: disable=too-many-branches, too-many-statements, too-many-arguments
def from_config_dict(config, hass=None, config_dir=None, enable_log=True,
                     verbose=False, daemon=False, skip_pip=False,
                     log_rotate_days=None):
    """Try to configure Home Assistant from a config dict.

    Dynamically loads required components and its dependencies.
    """
    if hass is None:
        hass = core.HomeAssistant()
        if config_dir is not None:
            config_dir = os.path.abspath(config_dir)
            hass.config.config_dir = config_dir
            mount_local_lib_path(config_dir)

    core_config = config.get(core.DOMAIN, {})

    try:
        process_ha_core_config(hass, config_util.CORE_CONFIG_SCHEMA(
            core_config))
    except vol.MultipleInvalid as ex:
        cv.log_exception(_LOGGER, ex, 'homeassistant', core_config)
        return None

    process_ha_config_upgrade(hass)

    if enable_log:
        enable_logging(hass, verbose, daemon, log_rotate_days)

    hass.config.skip_pip = skip_pip
    if skip_pip:
        _LOGGER.warning('Skipping pip installation of required modules. '
                        'This may cause issues.')

    _ensure_loader_prepared(hass)

    # Make a copy because we are mutating it.
    # Convert it to defaultdict so components can always have config dict
    # Convert values to dictionaries if they are None
    config = defaultdict(
        dict, {key: value or {} for key, value in config.items()})

    # Filter out the repeating and common config section [homeassistant]
    components = set(key.split(' ')[0] for key in config.keys()
                     if key != core.DOMAIN)

    if not core_components.setup(hass, config):
        _LOGGER.error('Home Assistant core failed to initialize. '
                      'Further initialization aborted.')

        return hass

    _LOGGER.info('Home Assistant core initialized')

    # Give event decorators access to HASS
    event_decorators.HASS = hass
    service.HASS = hass

    # Setup the components
    for domain in loader.load_order_components(components):
        _setup_component(hass, domain, config)

    return hass


def from_config_file(config_path, hass=None, verbose=False, daemon=False,
                     skip_pip=True, log_rotate_days=None):
    """Read the configuration file and try to start all the functionality.

    Will add functionality to 'hass' parameter if given,
    instantiates a new Home Assistant object if 'hass' is not given.
    """
    if hass is None:
        hass = core.HomeAssistant()

    # Set config dir to directory holding config file
    config_dir = os.path.abspath(os.path.dirname(config_path))
    hass.config.config_dir = config_dir
    mount_local_lib_path(config_dir)

    enable_logging(hass, verbose, daemon, log_rotate_days)

    try:
        config_dict = config_util.load_yaml_config_file(config_path)
    except HomeAssistantError:
        return None

    return from_config_dict(config_dict, hass, enable_log=False,
                            skip_pip=skip_pip)


def enable_logging(hass, verbose=False, daemon=False, log_rotate_days=None):
    """Setup the logging."""
    if not daemon:
        logging.basicConfig(level=logging.INFO)
        fmt = ("%(log_color)s%(asctime)s %(levelname)s (%(threadName)s) "
               "[%(name)s] %(message)s%(reset)s")
        try:
            from colorlog import ColoredFormatter
            logging.getLogger().handlers[0].setFormatter(ColoredFormatter(
                fmt,
                datefmt='%y-%m-%d %H:%M:%S',
                reset=True,
                log_colors={
                    'DEBUG': 'cyan',
                    'INFO': 'green',
                    'WARNING': 'yellow',
                    'ERROR': 'red',
                    'CRITICAL': 'red',
                }
            ))
        except ImportError:
            pass

    # Log errors to a file if we have write access to file or config dir
    err_log_path = hass.config.path(ERROR_LOG_FILENAME)
    err_path_exists = os.path.isfile(err_log_path)

    # Check if we can write to the error log if it exists or that
    # we can create files in the containing directory if not.
    if (err_path_exists and os.access(err_log_path, os.W_OK)) or \
       (not err_path_exists and os.access(hass.config.config_dir, os.W_OK)):

        if log_rotate_days:
            err_handler = logging.handlers.TimedRotatingFileHandler(
                err_log_path, when='midnight', backupCount=log_rotate_days)
        else:
            err_handler = logging.FileHandler(
                err_log_path, mode='w', delay=True)

        err_handler.setLevel(logging.INFO if verbose else logging.WARNING)
        err_handler.setFormatter(
            logging.Formatter('%(asctime)s %(name)s: %(message)s',
                              datefmt='%y-%m-%d %H:%M:%S'))
        logger = logging.getLogger('')
        logger.addHandler(err_handler)
        logger.setLevel(logging.INFO)

    else:
        _LOGGER.error(
            'Unable to setup error log %s (access denied)', err_log_path)


def process_ha_config_upgrade(hass):
    """Upgrade config if necessary."""
    version_path = hass.config.path('.HA_VERSION')

    try:
        with open(version_path, 'rt') as inp:
            conf_version = inp.readline().strip()
    except FileNotFoundError:
        # Last version to not have this file
        conf_version = '0.7.7'

    if conf_version == __version__:
        return

    _LOGGER.info('Upgrading config directory from %s to %s', conf_version,
                 __version__)

    # This was where dependencies were installed before v0.18
    # Probably should keep this around until ~v0.20.
    lib_path = hass.config.path('lib')
    if os.path.isdir(lib_path):
        shutil.rmtree(lib_path)

    lib_path = hass.config.path('deps')
    if os.path.isdir(lib_path):
        shutil.rmtree(lib_path)

    with open(version_path, 'wt') as outp:
        outp.write(__version__)


def process_ha_core_config(hass, config):
    """Process the [homeassistant] section from the config."""
    hac = hass.config

    def set_time_zone(time_zone_str):
        """Helper method to set time zone."""
        if time_zone_str is None:
            return

        time_zone = date_util.get_time_zone(time_zone_str)

        if time_zone:
            hac.time_zone = time_zone
            date_util.set_default_time_zone(time_zone)
        else:
            _LOGGER.error('Received invalid time zone %s', time_zone_str)

    for key, attr in ((CONF_LATITUDE, 'latitude'),
                      (CONF_LONGITUDE, 'longitude'),
                      (CONF_NAME, 'location_name')):
        if key in config:
            setattr(hac, attr, config[key])

    if CONF_TIME_ZONE in config:
        set_time_zone(config.get(CONF_TIME_ZONE))

    for entity_id, attrs in config.get(CONF_CUSTOMIZE).items():
        Entity.overwrite_attribute(entity_id, attrs.keys(), attrs.values())

    if CONF_TEMPERATURE_UNIT in config:
        hac.temperature_unit = config[CONF_TEMPERATURE_UNIT]

    # If we miss some of the needed values, auto detect them
    if None not in (
            hac.latitude, hac.longitude, hac.temperature_unit, hac.time_zone):
        return

    _LOGGER.warning('Incomplete core config. Auto detecting location and '
                    'temperature unit')

    info = loc_util.detect_location_info()

    if info is None:
        _LOGGER.error('Could not detect location information')
        return

    if hac.latitude is None and hac.longitude is None:
        hac.latitude = info.latitude
        hac.longitude = info.longitude

    if hac.temperature_unit is None:
        if info.use_fahrenheit:
            hac.temperature_unit = TEMP_FAHRENHEIT
        else:
            hac.temperature_unit = TEMP_CELSIUS

    if hac.location_name is None:
        hac.location_name = info.city

    if hac.time_zone is None:
        set_time_zone(info.time_zone)


def _ensure_loader_prepared(hass):
    """Ensure Home Assistant loader is prepared."""
    if not loader.PREPARED:
        loader.prepare(hass)