import pickle
import logging
import asyncio
from typing import Generator
from pathlib import Path
import os

from nflogic.api.parse import ParserInput, FactParser, FullParser, ParserInitError
from nflogic.api import db

SCRIPT_PATH = os.path.split(os.path.realpath(__file__))[0]
"""@private Caminho para este *script*, não alterar."""

CACHE_PATH = os.path.join(SCRIPT_PATH, "cache")
"""Caminho para o diretório onde os arquivos de cache são armazenados."""

LOG_PATH = os.path.join(SCRIPT_PATH, "log")
"""Caminho para o diretório onde os arquivos de log são armazenados."""

LOG_FILE = os.path.join(SCRIPT_PATH, "log", f"{__name__}.log")
"""Caminho para o arquivo de log deste módulo."""

for directory in [CACHE_PATH, LOG_PATH]:
    os.makedirs(directory, exist_ok=True)

log = logging.getLogger(__name__)
"""Logger deste módulo."""

log.setLevel(logging.INFO)

os.makedirs(os.path.split(LOG_FILE)[0], exist_ok=True)
loghandler = logging.FileHandler(filename=LOG_FILE)
logformat = logging.Formatter(fmt="%(asctime)s %(levelname)s :: %(message)s")
loghandler.setFormatter(logformat)
loghandler.setLevel(logging.INFO)
log.addHandler(loghandler)
log.propagate = False


def _get_success_cachename(full_parse: bool) -> str:
    """@public Utilitário que retorna o nome de cache de arquivos processados com
    sucesso.

    :param full_parse: Valor *booleano* indicando se deve obter o cache de sucesso em
        ambas as tabelas ou apenas na tabela *fato*.

    :return str: Uma *string* com o nome do arquivo de cache.
    """
    if full_parse:
        return "__both_table_success__"
    return "__fact_table_success__"


def valid_cachename(cachename: str) -> bool:
    """Verifica se um arquivo de cache existe *e* seu conteúdo é válido.

    :param cachename: Nome do arquivo sem extensão, use `get_cachenames` para ver os
        nomes disponíveis.

    :return: Resposta à verificação como um valor *booleano*.
    """
    cachefile_path = os.path.join(CACHE_PATH, f"{cachename}.cache")
    if not os.path.isfile(cachefile_path):
        return False
    c = CacheHandler(cachename, False)
    return c.is_valid()


def get_cachenames() -> list[str]:
    """Lista os nomes de cache disponíveis.

    :return: Uma *lista* dos nomes como *strings*."""
    cachenames = []
    for f in os.listdir(CACHE_PATH):
        fullpath = os.path.join(CACHE_PATH, f)
        no_ext_filename = os.path.splitext(f)[0]
        if os.path.isfile(fullpath) and valid_cachename(no_ext_filename):
            cachenames.append(no_ext_filename)
    return cachenames


def get_not_processed_inputs(
    filepaths: list[str],
    buy: bool,
    ignore_fails: bool,
    full_parse: bool,
) -> Generator[ParserInput, None, None]:
    """Cria um gerador de `.parse.ParserInput` que ainda não foram processados e
    registrados com sucesso no banco de dados.

    :param filepaths: *lista* de caminhos como *string* para os arquivos usados para o
        gerador. Estes valores farão parte da chave "path" dos `.parse.ParserInput`
        entregues pelo gerador.
    :param buy: Valor da chave "buy" dos `.parse.ParserInput` gerados. Indica se os
        arquivos devem ser processados como notas de compra ou de venda.
    :param ignore_fails: Valor *booleano* indicando se os *inputs* que falharam antes
        devem ser incluídos no gerador.
    :param full_parse: Valor *booleano*, se verdadeiro, indica que serão usados os
        dados do cache dos arquivos foram processados pelo `.parse.FullParser`, ou
        pelo `.parse.FactParser` caso contrário.
    """
    success_cache = CacheHandler(_get_success_cachename(full_parse), full_parse)
    ignore_data = success_cache.data
    if ignore_fails:
        fail_cache = CacheHandler("__could_not_parse_xml__", full_parse)
        ignore_data = ignore_data + fail_cache.data
    return (
        {"path": file, "buy": buy}
        for file in filepaths
        if {"path": file, "buy": buy} not in ignore_data
    )


def _save_successfull_fileparse(parser: FactParser | FullParser):
    """Registra este *parser* no cache de processamentos realizados com sucesso.
    Não fará nada se ele tiver algum erro ou se ainda não tiver processado o seu
    documento.

    :param parser: Objeto .parse.FactParser ou .parse.FullParser com dados já
        processados através de .parse.FactParser.parse() ou .parse.FullParser.parse().
    """
    expected_types = [FactParser, FullParser]
    if not isinstance(parser, (FactParser, FullParser)):
        raise ValueError(
            f"Parser should be one of {expected_types=}, got {type(parser)}."
        )
    if not parser._parsed():
        return
    full_parse = True if type(parser) is FullParser else False
    cachename = _get_success_cachename(full_parse=full_parse)
    success_cache = CacheHandler(cachename=cachename, full_parse=full_parse)
    if parser.INPUTS not in success_cache.data:
        success_cache.add(parser.INPUTS)


def _save_failed_parser_init(parser: FactParser | FullParser):
    """Registra este parser no cache de *parser* que não puderam ser inicializados.
    Não fará nada se não encontrar um `.parse.ParserInitError` neste *parser*.

    :param parser: Objeto .parse.FactParser ou .parse.FullParser com dados já
        processados através de .parse.FactParser.parse() ou .parse.FullParser.parse().
    """
    expected_types = [FactParser, FullParser]
    if type(parser) not in expected_types:
        raise ValueError(
            f"Parser should be one of {expected_types=}, got {type(parser)}."
        )
    if ParserInitError not in parser.err:
        return
    full_parse = False
    if type(parser) is FullParser:
        full_parse = True
    fail_cache = CacheHandler("__could_not_parse_xml__", full_parse)
    if parser.INPUTS not in fail_cache.data:
        fail_cache.add(parser.INPUTS)


class KeyAlreadyProcessedError(Exception):
    """Erro que indica que um arquivo já foi processado antes."""

    def __init__(self, *args: object) -> None:
        super().__init__(*args)


class KeyNotFoundError(Exception):
    """Erro que indica que uma chave não pôde ser encontrada no arquivo."""

    def __init__(self, *args: object) -> None:
        super().__init__(*args)


class CacheHandler:
    def __init__(self, cachename: str | None, full_parse: bool = False) -> None:
        """Lida com registros do trabalho de *parsers* no cache, guardando os inputs
        para evitar que arquivos sejam processados sem necessidade.

        :param cachename: Nome do arquivo de cache sem extensão. Se `cachename=None`,
            usará `"COULD_NOT_GET_NAME"`.
        :param full_parse: Valor *booleano* que sinaliza que o *parser* usado é
            `.parse.FullParser` quando verdadeiro, ou `.parse.FactParser` caso
            contrário.
        """
        if not cachename:
            self.cachename = "COULD_NOT_GET_NAME"
        else:
            # Remove bars to avoid creating an exotic path
            transform = str.maketrans("", "", r"\/")
            cachename = cachename.translate(transform)
            self.cachename = db.fmt_tablename(cachename)
        if full_parse:
            self.cachename = f"FULL {cachename}"

        self.cachefile: Path = Path(CACHE_PATH, f"{cachename}.cache")
        """Caminho para o arquivo de cache refletido por este `CacheHandler`."""

        self.data: list[ParserInput] = self._load()
        """*Lista* de `.parse.ParserInput` registrados neste cache."""

    def _load(self) -> list[ParserInput]:
        """Lê o conteúdo do arquivo de cache e carrega em `CacheHandler.data`."""
        self.cachefile.touch(exist_ok=True)  # Should ensure file exists on every read.
        with open(self.cachefile, "rb") as cache:
            try:
                output: list = pickle.load(cache)
            except EOFError:
                output = []
            return output

    def _heal(self) -> None:
        """Compara o cache em memória com o cache em disco, persistindo a cópia com
        mais itens.
        """
        size_diff = len(self._load()) - len(self.data)
        if size_diff == 0:
            return
        log.warning(
            f"Inconsistente cache sizes, starting '_heal' method from {self.cachename}.cache"
        )

        if size_diff < 0:
            # recreate file and write contents from memory
            Path(self.cachefile).touch(exist_ok=True)
            with open(self.cachefile, "wb") as cache:
                pickle.dump(obj=self.data, file=cache)
            log.info(f"Restored {abs(size_diff)} items to {self.cachename}.cache")

        else:
            # read file contents
            self.data = self._load()
            log.info(f"Restored {size_diff} items from {self.cachename}.cache")

    def _check_item(self, item: ParserInput):
        """Verifica se o `item` recebido é um `.parse.ParserInput` válido.

        :param item: Elemento a ser verificado.

        :raises TypeError: Se o elemento não é um `.parse.ParserInput` válido.
        """
        for param, typ in ParserInput.__annotations__.items():
            if type(item[param]) is not typ:
                raise TypeError(f"{item} is not of `ParserInput` type.")

    def _first_invalid_elem(self) -> ParserInput | None:
        """Retorna o primeiro item em `CacheHandler.data` que não é um
        `.parse.ParserInput` válido, ou `None` caso todos sejam válidos.
        """
        for idx, elem in enumerate(self.data):
            if not isinstance(elem, dict):
                print(f"self.data[{idx}] is not dict-like")
                return elem
            try:
                _, _ = elem["path"], elem["buy"]
            except KeyError:
                print(f"self.data[{idx}] doesn't include required keys")
                return elem
            if type(elem["path"]) != str:
                print(f"Found key self.data[{idx}]['path']={elem['path']} not str")
                return elem
            if type(elem["buy"]) != bool:
                print(f"Found key self.data[{idx}]['buy']={elem['buy']} not bool")
                return elem
        return None

    def is_valid(self) -> bool:
        """Testa se a estrutura de seus próprios dados `CacheHandler.data` está
        formatada adequadamente como uma lista de `.parse.ParserInput`.

        :return: Valor *booleano* indicando se este `CacheHandler` é válido.
        """
        if type(self.data) != list:
            print("self.data is not list")
            return False
        invalid_elem = self._first_invalid_elem()
        if invalid_elem:
            return False
        return True

    def add(self, item: ParserInput) -> None:
        """Adiciona um `.parse.ParserInput` ao cache.

        :raises `KeyAlreadyProcessedError`: Se já estiver registrado.
        """
        self._check_item(item=item)
        if item in self._load():
            raise KeyAlreadyProcessedError(f"{item} já está na lista")
        self._heal()
        with open(self.cachefile, "wb") as cache:
            pickle.dump(obj=self.data + [item], file=cache)
        self.data = self._load()

    def rm(self, item: ParserInput) -> None:
        """Remove um `.parse.ParserInput` do cache.

        :raises `KeyNotFoundError`: Se não estiver registrado.
        """
        if item not in self.data:
            file_name = os.path.split(self.cachefile)[1]
            raise KeyNotFoundError(f"Arquivo não foi registrado em {file_name}")
        self._heal()
        with open(self.cachefile, "wb") as cache:
            idx = self.data.index(item)
            _ = self.data.pop(idx)
            pickle.dump(obj=self.data, file=cache)
        self.data = self._load()


class ParserManipulator:
    def __init__(
        self,
        full_parse: bool = True,
        ignore_cached_errors: bool = True,
        db_path: str | Path = db.DB_PATH,
    ):
        """Resolve o *workflow* de coleta de dados e registro no cache de múltiplos parsers.

        :param full_parse: Whether the parsers should get all data from the documents
          or just the payment info.
        :param ignore_cached_errors: Wether documents that failed to process before
          should be ignored.
        :param  db_path: Caminho para o arquivo de banco de dados onde a consulta será
            realizada.
        """
        self.full_parse: bool = full_parse
        """Valor *booleano* indicando o tipo de *parser* produzido por este
        `ParserManipulator`.
        """

        self.n_parsed: int = 0
        """Número de documentos processados."""

        self.n_failed: int = 0
        """Número de documentos processados com erro."""

        self.n_skipped: int = 0
        """Número de documentos não processados por já estar presente no banco de
        dados.
        """

        self.n_recovered: int = 0
        """Número de documentos que já foram processados com erro anteriormente, mas
        que foram processados com sucesso desta vez.
        """

        self.db_path: str | Path = db_path
        """Caminho para o arquivo de banco de dados onde a consulta será realizada."""

    def add_parser(self, parser_input: ParserInput):
        """Cria, testa e registra os dados de um *parser*.

        :param parser_input: Parâmetros usados para criar o *parser*.
        """
        parser = self._test_return_parser(parser_input)
        self.n_parsed = self.n_parsed + 1
        if parser.erroed():
            self._handle_cache_registry(parser=parser)
        rows_in_db = db.all_rows_in_db(parser, db_path=self.db_path)
        if asyncio.to_thread(rows_in_db):
            self.n_skipped = self.n_skipped + 1
            _save_successfull_fileparse(parser)
            return
        self._handle_parser_success(parser=parser)

    def _get_parser(self, parser_input: ParserInput) -> FactParser | FullParser:
        """Produz um *parser* com base no valor atual de
        `ParserManipulator.full_parse`.

        :param parser_input: Parâmetros usados para criar o *parser*.

        :return `.parse.FactParser` | `.parse.FullParser`: *Parser produzido.
        """
        if self.full_parse:
            return FullParser(parser_input)
        else:
            return FactParser(parser_input)

    def _test_return_parser(self, parser_input: ParserInput) -> FactParser | FullParser:
        """Cria um *parser* e o retorna, será contabilizado em
        `ParserManipulator.n_fails` se houver algum erro ao inicializá-lo.

        :param parser_input: Um `.parse.ParserInput` válido.

        :return: Um `.parse.FactParser` se `ParserManipulator.full_parse = False`, ou
            um `.parse.FullParser` caso contrário.
        """
        parser = self._get_parser(parser_input)
        if parser.erroed():
            _save_failed_parser_init(parser)
            return parser
        parser.parse()
        if parser.erroed():
            cache_handler = CacheHandler(parser.name, self.full_parse)
            if parser.INPUTS not in cache_handler.data:
                cache_handler.add(parser.INPUTS)
        return parser

    def _remove_successful_parser_from_cache(self, parser: FactParser | FullParser):
        """Se o *parser* processou o arquivo e não teve erros, remove-o do cache de
        erros, não faz nada caso contrário. Este parser será contabilizado em
        `ParserManipulator.n_recovered`.

        :param parser: Um `.parse.FactParser` se `ParserManipulator.full_parse = False`, ou
            um `.parse.FullParser` caso contrário.
        """
        self.n_recovered = self.n_recovered + 1
        if (len(parser.err) > 0) or (len(parser.data) == 0):
            return
        init_cache, parse_cache = self._get_cache_handlers(parser=parser)
        if parser.INPUTS in init_cache.data:
            init_cache.rm(parser.INPUTS)
        if parser.INPUTS in parse_cache.data:
            parse_cache.rm(parser.INPUTS)

    def _add_failed_parser_to_cache(self, parser: FactParser | FullParser):
        """Se o parser tiver algum erro, adiciona-o à todos os cache de erros
        pertinentes, não faz nada caso contrário. Este parser será contabilizado em
        `ParserManipulator.n_failed`.

        :param parser: Um `.parse.FactParser` se `ParserManipulator.full_parse = False`, ou
            um `.parse.FullParser` caso contrário.
        """
        self.n_failed = self.n_failed + 1
        if (len(parser.err) == 0) or (len(parser.data) > 0):
            return
        init_cache, parse_cache = self._get_cache_handlers(parser=parser)
        err_cls = [type(err) for err in parser.err]
        if (parser.INPUTS not in init_cache.data) and (ParserInitError in err_cls):
            init_cache.add(parser.INPUTS)
        has_non_init_err = any(type(err) != ParserInitError for err in parser.err)
        if (parser.INPUTS not in parse_cache.data) and has_non_init_err:
            parse_cache.add(parser.INPUTS)

    def _get_cache_handlers(
        self, parser: FactParser | FullParser
    ) -> tuple[CacheHandler, CacheHandler]:
        """Retorna dois `CacheHandler` de erros para o *parser*, o primeiro para erros
        de inicialização, e o segundo para outros tipos de erros.

        :param parser: Um `.parse.FactParser` se `ParserManipulator.full_parse = False`, ou
            um `.parse.FullParser` caso contrário.

        :return: Uma *Tupla* com duas instâncias de `CacheHandler`.
        """
        init_fail_cache = CacheHandler("__could_not_parse_xml__", self.full_parse)
        parse_fail_cache = CacheHandler(parser.name, self.full_parse)
        return init_fail_cache, parse_fail_cache

    def _handle_parser_success(
        self,
        parser: FactParser | FullParser,
    ):
        """Registra os dados do *parser* no banco de dados e lida com registros nos
        arquivos de cache relevantes.

        :param parser: Um `.parse.FactParser` se `ParserManipulator.full_parse = False`, ou
            um `.parse.FullParser` caso contrário.
        """
        asyncio.run(db.insert_rows(parser=parser, db_path=self.db_path))
        _save_successfull_fileparse(parser)
        self._remove_successful_parser_from_cache(parser)

    def _handle_cache_registry(self, parser: FactParser | FullParser):
        """Função ajudante que chama resolve as chamadas para
        `ParserManipulator._remove_successful_parser_from_cache` e
        `ParserManipulator._add_failed_parser_to_cache`.
        """
        init_fail_cache, parse_fail_cache = self._get_cache_handlers(parser=parser)
        in_init_fail_cache: bool = parser.INPUTS in init_fail_cache.data
        in_parse_fail_cache: bool = parser.INPUTS in parse_fail_cache.data
        if in_init_fail_cache or in_parse_fail_cache:
            self._remove_successful_parser_from_cache(parser=parser)
        else:
            self._add_failed_parser_to_cache(parser=parser)
