"""
Code in charge of parsing and validating Snakemake syntax
"""
import tokenize
from abc import ABC, abstractmethod
from re import match as re_match
from typing import NamedTuple, Optional

from snakefmt.exceptions import (
    ColonError,
    EmptyContextError,
    InvalidParameter,
    InvalidParameterSyntax,
    NewlineError,
    NoParametersError,
    NotAnIdentifierError,
    SyntaxFormError,
    TooManyParameters,
)
from snakefmt.types import (
    COMMENT_SPACING,
    TAB,
    Token,
    TokenIterator,
    col_nb,
    line_nb,
    not_empty,
)

# ___Token parsing___#
QUOTES = {'"', "'"}
BRACKETS_OPEN = {"(", "[", "{"}
BRACKETS_CLOSE = {")", "]", "}"}

# ___Token spacing: for when cannot run black___#
spacing_triggers = {
    tokenize.NAME: {tokenize.NAME, tokenize.STRING, tokenize.NUMBER, tokenize.OP},
    tokenize.STRING: {tokenize.NAME, tokenize.OP},
    tokenize.NUMBER: {tokenize.NAME, tokenize.OP},
    tokenize.OP: {tokenize.NAME, tokenize.STRING, tokenize.NUMBER, tokenize.OP},
}


def operator_skip_spacing(prev_token: Token, token: Token) -> bool:
    if prev_token.type != tokenize.OP and token.type != tokenize.OP:
        return False
    if (
        prev_token.string in BRACKETS_OPEN
        or prev_token.string == "."
        or token.string in BRACKETS_CLOSE
        or token.string in {"[", ":", "."}
    ):
        return True
    elif prev_token.type == tokenize.NAME and token.string == "(":
        return True
    else:
        return False


def add_token_space(prev_token: Token, token: Token) -> bool:
    result = False
    if prev_token is not None and prev_token.type in spacing_triggers:
        if not operator_skip_spacing(prev_token, token):
            if token.type in spacing_triggers[prev_token.type]:
                result = True
    return result


def is_colon(token: Token):
    return token.type == tokenize.OP and token.string == ":"


def is_newline(token: Token):
    return token.type == tokenize.NEWLINE or token.type == tokenize.NL


def brack_open(token: Token):
    return token.type == tokenize.OP and token.string in BRACKETS_OPEN


def brack_close(token: Token):
    return token.type == tokenize.OP and token.string in BRACKETS_CLOSE


def is_equal_sign(token: Token):
    return token.type == tokenize.OP and token.string == "="


def is_comma_sign(token: Token):
    return token.type == tokenize.OP and token.string == ","


class Parameter:
    """
    Holds the value of a parameter-accepting keyword
    """

    def __init__(self, token: Token):
        self.line_nb = line_nb(token)
        self.col_nb = col_nb(token)
        self.key = ""
        self.value = ""
        self.pre_comments, self.post_comments = list(), list()
        self.len = 0
        self.inline: bool = True
        self.fully_processed: bool = False
        self._has_inline_comment: bool = False

    def __repr__(self):
        if self.has_a_key():
            return f"{self.key}={self.value}"
        else:
            return self.value

    def is_empty(self) -> bool:
        return str(self) == ""

    def add_comment(self, comment: str, indent_level: int) -> None:
        if self.is_empty():
            self.pre_comments.append(comment)
        else:
            if self.inline:
                self._has_inline_comment = True
            self.post_comments.append(comment)

    def has_a_key(self) -> bool:
        return len(self.key) > 0

    def has_value(self) -> bool:
        return len(self.value) > 0

    def add_elem(self, prev_token: Token, token: Token):
        if add_token_space(prev_token, token) and len(self.value) > 0:
            self.value += " "

        if self.is_empty():
            self.col_nb = col_nb(token)

        self.value += token.string

    def to_key_val_mode(self, token: Token):
        if not self.has_value():
            raise InvalidParameterSyntax(
                f"L{token.start[0]}:Operator = used with no preceding key"
            )
        try:
            exec(f"{self.value} = 0")
        except SyntaxError:
            raise InvalidParameterSyntax(
                f"L{token.start[0]}:Invalid key {self.value}"
            ) from None
        self.key = self.value
        self.value = ""


class Vocabulary:
    """
    Responsible for recognising snakemake keywords
    """

    spec = dict()

    def recognises(self, keyword: str) -> bool:
        return keyword in self.spec

    def get(self, keyword: str):
        return self.spec[keyword]


class Syntax(ABC):
    """
    Responsible for reading and processing tokens
    Classes derived from it raise syntax errors when snakemake syntax is not respected
    """

    class Status(NamedTuple):
        """Communicates the result of parsing a chunk of code"""

        token: Token
        indent: int
        buffer: str
        eof: bool
        pythonable: bool

    def __init__(
        self, keyword_name: str, target_indent: int, snakefile: TokenIterator = None
    ):
        self.keyword_name = keyword_name
        self.keyword_line = keyword_name
        assert target_indent >= 0
        self.target_indent = target_indent
        self.cur_indent = max(self.target_indent - 1, 0)
        self.code_indent = None
        self.comment = ""
        self.token = None

        if snakefile is not None:
            self.validate_keyword_line(snakefile)
            if self.token.type == tokenize.COMMENT:
                self.comment = f"{COMMENT_SPACING}{self.token.string}"
                self.token = next(snakefile)

    @abstractmethod
    def validate_keyword_line(self, snakefile: TokenIterator):
        """Checks the keyword-containing line is syntactically valid"""

    @property
    def line_nb(self):
        return f"L{line_nb(self.token)}: "


class KeywordSyntax(Syntax):
    """Parses snakemake keywords that accept other keywords, eg 'rule'"""

    def __init__(
        self,
        keyword_name: str,
        target_indent: int,
        snakefile: TokenIterator = None,
        incident_syntax: "KeywordSyntax" = None,
        from_python: bool = False,
        accepts_py: bool = False,
    ):
        self.enter_context = True
        super().__init__(keyword_name, target_indent, snakefile)
        self.processed_keywords = set()
        self.accepts_python_code = accepts_py
        self.queriable = True
        self.from_python = from_python

        if incident_syntax is not None:
            if self.token.type != tokenize.NEWLINE:
                NewlineError(line_nb, self.keyword_line)
            if not from_python:
                incident_syntax.add_processed_keyword(self.token, self.keyword_line)

    def validate_keyword_line(self, snakefile: TokenIterator):
        self.token = next(snakefile)

        if self.keyword_name == "use":
            self.validate_userule_syntax(snakefile)
        else:
            self.validate_rulelike_syntax(snakefile)

    def validate_userule_syntax(self, snakefile: TokenIterator):
        identifier = r"[a-zA-Z_]\S*"
        use_syntax_regexp = (
            r"use rule (?:(?:{id})|\*)"
            r"(?: from {id})?(?: as {id})?( with[ ]?:)?$".format(id=identifier)
        )
        use_ebnf_syntax = (
            '"use" "rule" (identifier | "*") '
            '"from" identifier ["as" identifier] ["with" ":"]'
        )
        while not is_newline(self.token):
            if self.token.type == tokenize.COMMENT:
                break
            # Tokenizing splits up '<identifier>*' into two tokens
            if self.token.string != "*":
                self.keyword_line += " "
            self.keyword_line += self.token.string
            try:
                self.token = next(snakefile)
            except StopIteration:
                break

        self.keyword_line = self.keyword_line.replace(
            "use rule*", "use rule *"
        ).replace("as*", "as *")
        match = re_match(use_syntax_regexp, self.keyword_line)
        if match is None:
            SyntaxFormError(self.line_nb, self.keyword_line, use_ebnf_syntax)
        if match.groups()[0] is None:
            self.enter_context = False
        else:
            # Gets added at formatting
            self.keyword_line = self.keyword_line.rstrip(": ")

    def validate_rulelike_syntax(self, snakefile: TokenIterator):
        if not is_colon(self.token):
            if self.token.type != tokenize.NAME:
                raise NotAnIdentifierError(
                    self.line_nb, self.token.string, self.keyword_line
                )
            self.keyword_line += f" {self.token.string}"
            self.token = next(snakefile)
        if not is_colon(self.token):
            ColonError(self.line_nb, self.token.string, self.keyword_line)
        self.token = next(snakefile)

    def add_processed_keyword(self, token: Token, keyword: str):
        self.processed_keywords.add(keyword)

    def check_empty(self):
        if len(self.processed_keywords) == 0:
            raise EmptyContextError(
                f"{self.line_nb}{self.keyword_name} has no keywords attached to it."
            )

    @property
    def effective_indent(self) -> int:
        return max(0, self.cur_indent - self.target_indent)

    def get_next_queriable(self, snakefile: TokenIterator) -> Syntax.Status:
        """Produces the next word that could be a snakemake keyword,
        and additional information in a :Syntax.Status:
        """
        buffer = ""
        newline = False
        pythonable = False
        prev_token: Optional[Token] = Token(tokenize.NAME)
        while True:
            token = next(snakefile)
            if token.type == tokenize.INDENT:
                self.cur_indent += 1
                prev_token = None
                continue
            elif token.type == tokenize.DEDENT:
                if self.cur_indent > 0:
                    self.cur_indent -= 1
                prev_token = None
                continue
            elif token.type == tokenize.ENDMARKER:
                return self.Status(token, self.cur_indent, buffer, True, pythonable)
            elif token.type == tokenize.COMMENT:
                if token.start[1] == 0:
                    return self.Status(token, 0, buffer, False, pythonable)
            elif is_newline(token):
                self.queriable, newline = True, True
                buffer += "\n"
                prev_token = None
                continue

            # Records relative tabbing, used for python code formatting
            if newline and not token.type == tokenize.COMMENT:
                buffer += TAB * self.effective_indent

            if token.type == tokenize.NAME and self.queriable:
                self.queriable = False
                return self.Status(token, self.cur_indent, buffer, False, pythonable)

            if add_token_space(prev_token, token):
                buffer += " "
            prev_token = token
            if newline:
                newline = False
            if not pythonable and token.type != tokenize.COMMENT:
                pythonable = True
            buffer += token.string


class ParameterSyntax(Syntax):
    """Parses snakemake keywords that do not accept other keywords, eg 'input'"""

    def __init__(
        self,
        keyword_name: str,
        target_indent: int,
        incident_vocab: Vocabulary,
        snakefile: TokenIterator,
    ):
        super().__init__(keyword_name, target_indent, snakefile)
        self.positional_params, self.keyword_params = list(), list()
        self.eof = False
        self.incident_vocab = incident_vocab
        self._brackets = list()
        self.in_lambda = False
        self.found_newline = False

        self.parse_params(snakefile)

    def validate_keyword_line(self, snakefile: TokenIterator):
        self.token = next(snakefile)

        if not is_colon(self.token):
            ColonError(self.line_nb, self.token.string, self.keyword_line)
        self.token = next(snakefile)

    @property
    def all_params(self):
        return self.positional_params + self.keyword_params

    @property
    def in_brackets(self):
        return len(self._brackets) > 0

    def parse_params(self, snakefile: TokenIterator):
        cur_param = Parameter(self.token)
        prev_token = None

        while True:
            cur_param = self.process_token(cur_param, prev_token)
            try:
                prev_token = self.token
                self.token = next(snakefile)
            except StopIteration:
                self.flush_param(cur_param, skip_empty=True)
                self.eof = True
                break
            if self.check_exit(cur_param):
                break

        if self.num_params() == 0:
            raise NoParametersError(f"{self.line_nb}In {self.keyword_name} definition.")

    def check_exit(self, cur_param: Parameter):
        exit = False
        if not self.found_newline:
            return exit
        if not_empty(self.token):
            # Special condition for comments: they appear before indents/dedents.
            if self.token.type == tokenize.COMMENT:
                if not cur_param.is_empty() and col_nb(self.token) < cur_param.col_nb:
                    exit = True
            else:
                exit = self.cur_indent < self.target_indent
            if exit:
                self.flush_param(cur_param, skip_empty=True)
        return exit

    def process_token(self, cur_param: Parameter, prev_token: Token) -> Parameter:
        token_type = self.token.type
        # Eager treatment of comments: tag them onto params
        if token_type == tokenize.COMMENT and not self.in_brackets:
            cur_param.add_comment(self.token.string, self.target_indent)
            return cur_param
        if is_newline(self.token):  # Special treatment for inline comments
            if not cur_param.is_empty():
                cur_param.inline = False
            if cur_param.has_value():
                cur_param.add_elem(prev_token, self.token)
            self.found_newline = True
            return cur_param

        if cur_param.fully_processed:
            self.flush_param(cur_param)
            cur_param = Parameter(self.token)

        if token_type == tokenize.INDENT:
            self.cur_indent += 1
        elif token_type == tokenize.DEDENT:
            if self.cur_indent > 0:
                self.cur_indent -= 1
        elif is_equal_sign(self.token) and not self.in_brackets:
            cur_param.to_key_val_mode(self.token)
        elif is_comma_sign(self.token) and not self.in_brackets and not self.in_lambda:
            cur_param.fully_processed = True
        elif token_type != tokenize.ENDMARKER:
            if brack_open(self.token):
                self._brackets.append(self.token.string)
            if brack_close(self.token):
                self._brackets.pop()
            if is_colon(self.token) and self.in_lambda:
                self.in_lambda = False
            if len(cur_param.value.split()) == 1:
                if cur_param.value.lstrip() == "lambda":
                    self.in_lambda = True
            cur_param.add_elem(prev_token, self.token)
        return cur_param

    def flush_param(self, parameter: Parameter, skip_empty: bool = False) -> None:
        if not parameter.has_value() and skip_empty:
            return

        if parameter.has_a_key():
            self.keyword_params.append(parameter)
        else:
            self.positional_params.append(parameter)

    def num_params(self):
        return len(self.keyword_params) + len(self.positional_params)


# ___Parameter Syntax Validators___#
class SingleParam(ParameterSyntax):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        if self.num_params() > 1:
            raise TooManyParameters(
                (
                    f"{self.line_nb}{self.keyword_name} definition expects a single "
                    f"parameter"
                )
            )
        if not len(self.keyword_params) == 0:
            raise InvalidParameter(
                (
                    f"{self.line_nb}{self.keyword_name} definition requires a "
                    f"positional (not key/value) parameter"
                )
            )


class ParamList(ParameterSyntax):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class InlineSingleParam(SingleParam):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class NoKeyParamList(ParamList):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        if len(self.keyword_params) > 0:
            raise InvalidParameterSyntax(
                (
                    f"{self.line_nb}{self.keyword_name} definition does not accept "
                    f"key/value parameters"
                )
            )
