#
# Copyright (c) 2023 Jared Crapo
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
"""class for storing and processing a pattern"""

import contextlib
import subprocess

import jinja2
import tomlkit
from benedict import benedict

from .exceptions import DyeError, DyeSyntaxError
from .filters import jinja_filters
from .scope import Scope
from .utils import DefinitionSource, merge_and_process_colors, merge_and_process_styles


class Pattern:
    """load and parse a pattern file into a pattern object"""

    @staticmethod
    def loads(tomlstring=None, theme=None):
        """Load a pattern from a given string, and return a new pattern object

        doesn't do any processing or applying of the pattern
        """
        if tomlstring:  # noqa: SIM108
            toparse = tomlstring
        else:
            # tomlkit can't parse None, so if we got it as the default
            # or if the caller pased None intentionally...
            toparse = ""
        pattern = Pattern()
        pattern.definition = benedict(tomlkit.loads(toparse))
        pattern.process(theme)
        return pattern

    @staticmethod
    def load(fobj, theme=None, filename=None):
        """Load a pattern a file object

        doesn't do any processing or applying of the pattern
        """
        pattern = Pattern()
        pattern.definition = benedict(tomlkit.load(fobj))
        pattern.filename = filename
        pattern.process(theme)

        return pattern

    #
    # initialization and properties
    #
    def __init__(self):
        """Construct a new Pattern object"""

        # the raw toml definition of the pattern
        self.definition = benedict()

        # if we have applied a theme to the pattern, store it here
        self.theme = None

        # these contain the core parts of the pattern,
        # but they have all been processed through the template
        # so they can be used by consumers of our class.
        # these are all set by process()
        self.colors = benedict()
        self.styles = benedict()
        self.variables = benedict()
        self.scopes = benedict()

        # metadata and pattern info
        self.description = None
        self.type = None
        self.version = None
        self.prevent_themes = False
        self.requires_theme = None

        # a place to stash the file that the pattern was loaded from
        # it's up to the caller/user to make sure this is set properly
        # defaults to None
        self.filename = None

    def __bool__(self):
        """Falsy if the definition is falsy, truthy if the definition is truthy"""
        return bool(self.definition)

    def process(self, theme=None):
        """Process the loaded pattern definition, merging in a theme if given

        returns nothing, populates stuff in the current object:

            .theme
            .colors
            .styles
            .variables
            .scopes
        """
        self.theme = theme

        self.description = self.definition.get("description", None)
        self.type = self.definition.get("type", None)
        self.version = self.definition.get("version", None)

        prevent = self.definition.get("prevent_themes", False)
        if not isinstance(prevent, bool):
            raise DyeSyntaxError("'prevent_themes' must be true or false")
        self.prevent_themes = prevent

        self.requires_theme = self.definition.get("requires_theme", None)

        jinja_env = jinja2.Environment()
        jinja_env.filters = jinja_filters()

        self._process_colors(jinja_env)
        self._process_styles(jinja_env)
        self._process_variables(jinja_env)
        self._process_scopes()

    def _process_colors(self, jinja_env):
        """merge the colors from the pattern and self.theme together

        this sets self.colors

        A color can be referenced in this section in any of these ways:

        foreground = "#f8f8f2"
        foreground_high = "foreground"
        foreground_medium = "{{ colors.foreground }}"
        foreground_low = "{{ color.foreground }}"

        A color is just a string, there is nothing done to the value of the color.
        You could do everything with a variable that you can do with a color,
        it's just a convenient way to group/name them.
        """
        self.colors = self.theme.colors.copy() if self.theme else benedict()
        pattern_colors = benedict()
        with contextlib.suppress(KeyError):
            pattern_colors = benedict(self.definition["colors"])
        merge_and_process_colors(self.colors, pattern_colors, jinja_env)

    def _process_styles(self, jinja_env):
        """merge the styles from this pattern and the given theme together

        this sets self.styles
        """
        self.styles = self.theme.styles.copy() if self.theme else benedict()
        pattern_styles = benedict()
        with contextlib.suppress(KeyError):
            pattern_styles = benedict(self.definition["styles"])
        merge_and_process_styles(self.styles, pattern_styles, jinja_env, self.colors)

    def _process_variables(self, jinja_env):
        """process the variables into self.variables"""
        # Process the capture variables without rendering.
        # We can't render because the toml parser has to group
        # all the "capture" items in a separate table, they can't be
        # interleaved with the regular variables in the order they are
        # defined. So we have to choose to process either the [variables]
        # table or the [variables][capture] table first. We choose the
        # [variables][capture] table.
        #
        # no technical reason why we don't render colors and styles
        # in capture variables
        processed_vars = {}
        try:
            cap_vars = self.definition["variables"]["capture"]
        except KeyError:
            cap_vars = {}
        for var, cmd in cap_vars.items():
            proc = subprocess.run(cmd, shell=True, check=False, capture_output=True)
            if proc.returncode != 0:
                raise DyeError(
                    f"capture variable '{var}' returned a non-zero exit code."
                )
            processed_vars[var] = str(proc.stdout, "UTF-8")

        # then add the regular variables, processing them as templates
        try:
            # make a shallow copy, because we are gonna delete any capture
            # vars and we want the definition to stay pristine
            reg_vars = self.definition["variables"].copy()
        except KeyError:
            reg_vars = {}
        # if no capture variables, we don't care, if present
        # delete that table so we don't process it again
        with contextlib.suppress(KeyError):
            del reg_vars["capture"]

        for var, definition in reg_vars.items():
            if var in processed_vars:
                raise DyeError(f"variable '{var}' has already been defined.")
            template = jinja_env.from_string(definition)
            processed_vars[var] = template.render(
                color=self.colors,
                colors=self.colors,
                style=self.styles,
                styles=self.styles,
                var=processed_vars,
                vars=processed_vars,
                variable=processed_vars,
                variables=processed_vars,
            )

        self.variables = processed_vars

    def _process_scopes(self):
        """process value in every scope as a template to resolve
        colors, styles, and variables

        sets self.scopes as a dict of objects
        """
        raw_scopes = {}
        with contextlib.suppress(KeyError):
            raw_scopes = self.definition["scopes"]

        for name in raw_scopes:
            scope = Scope(name, self)
            self.scopes[name] = scope

    #
    # other methods
    #
    def has_scope(self, scope):
        """Check if the given scope exists."""
        return scope in self.scopes

    def get_color_def(self, color_name):
        """retrieve the definition of color_name, from wherever it was defined

        Args:
            color_name (str): the name of the color you want to retrieve

        Raises:
            KeyError: if color_name is not defined anywhere

        Returns:
            (source, color_def): a DefinitionSource of where the color_name is defined
            and a string of the definition
        """
        found = False
        source = None
        color_def = None

        if self.theme:
            with contextlib.suppress(KeyError):
                color_def = self.theme.definition["colors"][color_name]
                source = DefinitionSource.THEME
                found = True
        if self.definition:
            with contextlib.suppress(KeyError):
                color_def = self.definition["colors"][color_name]
                source = DefinitionSource.PATTERN
                found = True

        if not found:
            raise KeyError(color_name)
        return (source, color_def)

    def get_style_def(self, style_name):
        """retrieve the definition of style_name from theme or pattern"""
        found = False
        source = None
        style_def = None

        if self.theme:
            with contextlib.suppress(KeyError):
                style_def = self.theme.definition["styles"][style_name]
                source = DefinitionSource.THEME
                found = True
        if self.definition:
            with contextlib.suppress(KeyError):
                style_def = self.definition["styles"][style_name]
                source = DefinitionSource.PATTERN
                found = True

        if not found:
            raise KeyError(style_name)
        return (source, style_def)
