import toml
import ast
import re
import os
import pathlib
import markdown
from bs4 import BeautifulSoup
from django.db import models
from django.utils.translation import gettext as _
from start.models import Keyword, Searchable

import logging
logger = logging.getLogger(__name__)

EXTENSIONS = [
    "extra",
    "admonition",
    "codehilite",
    "meta",
    "toc"
]
EXTENSION_CONFIGS = {
    'codehilite': {
        'linenums': True,
        'pygments_style': 'monokai'
    },
}

MD = markdown.Markdown(extensions=EXTENSIONS,
                       extension_configs=EXTENSION_CONFIGS)


class Category(models.Model):
    """
    A category of blog posts

    Name not translated because it would make i18n in urls and Searchables specifically a pain.
    Maybe some day it would be cool if these were Searchable
    """
    name = models.CharField(max_length=50)
    slug = models.SlugField(unique=True)

    class Meta:
        verbose_name = _("Category")
        verbose_name_plural = _("Categories")

    def __str__(self):
        return f"{{<{self.__class__.__name__}>\"{self.name}\"}}"

    @staticmethod
    def get_or_create_uncategorized():
        try:
            return Category.objects.get(slug="uncategorized")
        except Category.DoesNotExist:
            return Category.objects.create(
                slug="uncategorized", name="uncategorized")


class BlogPost(Searchable):
    """
    Should contain a blogpost
    """
    DATA_DIR = "/app/blog/data/articles"
    DEFAULT_LANGS = {'en': False, 'de': False}
    META_TOP_KEYS = [
        "date",
        "update",
        "keywords",
        "category",
        "featured",
        "public",
        "lang"]
    META_LANG_KEYS = ["title", "subtitle", "desc"]

    body_en = models.TextField(blank=True,
                               default="""Dieser Artikel ist nicht auf deutsch verfügbar.""")
    body_de = models.TextField(blank=True,
                               default="""This aritcle is not available in english.""")
    category = models.ForeignKey(
        Category, on_delete=models.SET_DEFAULT, blank=False,
        default=Category.get_or_create_uncategorized)
    thumbnail = models.ImageField(
        blank=True,
        upload_to="img/thumbnails",
        default="img/thumbnails/default.jpg")
    featured = models.BooleanField(default=False)
    langs = models.CharField(
        default=DEFAULT_LANGS.__repr__(), max_length=64)
    slug = models.SlugField(unique=True, blank=False)

    def save(self):
        # check if the slug is empty if we remove whitespaces
        if len(self.slug.strip()) == 0:
            logger.error(
                f"trying to save '{self.__class__}' with empty slug: {self}")
            raise ValueError(f"trying to save '{self.__class__}' with empty slug: {self}")
        super().save()

    def regenerate(self):
        """
        regenerate a object

        Implements the abstract method of Searchable
        """
        logger.info(f"regenerating {self.__class__.__name__} object: {self}")
        # url stuff
        self.suburl = f"/blog/{self.category.name}/{self.slug}"

        # load from markdown
        # self.sync_file()

        # redundand vvvv
        self.save()

    @staticmethod
    def __patch_html(html: str) -> str:
        soup = BeautifulSoup(html)

        # add bootstrap classes to regular tables
        tables = soup.select("table")
        for table in tables:
            if 'class' in table.attrs and "codehilitetable" in table.attrs['class']:
                # table has at least one class already AND
                # this table is a codehighlighting table,
                # not a regular one
                continue
            # set the bootstrap classes for tables
            table.attrs['class'] = 'table table-striped border'
        return soup.prettify()

    def sync_file(self):
        """
        generate an article fromm it's original markdown file
        """
        logger = logging.getLogger(__name__)
        logger.info(f"syncing article to markdown for: {self}")
        # read metadata
        try:
            fmeta = open(f"{self.DATA_DIR}/{self.slug}.toml", "r")
        except Exception as e:
            logger.error(f"could not find meta file for '{self}'")
            return
        data = toml.load(fmeta)
        langs = self.get_langs()
        for key in self.META_TOP_KEYS:
            if key not in data:
                logger.error(f"Key '{key}' missing in meta file for '{self}'")
                raise ValueError(
                    f"Key '{key}' missing in meta file for '{self}'")
        for lang in self.DEFAULT_LANGS.keys():
            if lang not in data['lang']:
                langs[lang] = False
            else:
                langs[lang] = True
        self.set_langs(langs)
        for lang in data['lang']:
            for key in self.META_LANG_KEYS:
                if key not in data['lang'][lang]:
                    logger.warning(
                        f"Key '{key}' ('{lang}') missing in meta file for '{self}'")
                    langs[lang] = False
                else:
                    langs[lang] = True
            if not langs[lang]:
                # no translation for this language
                continue
            with open(f"{self.DATA_DIR}/{lang}-{self.slug}.md") as f_en:
                MD.reset()
                body: str = f_en.read()
                match lang:
                    case "en":
                        self.title_en = data['lang'][lang]["title"]
                        self.subtitle_en = data['lang'][lang]["subtitle"]
                        self.desc_en = data['lang'][lang]["desc"]
                        self.body_en = BlogPost.__patch_html(MD.convert(body))
                    case "de":
                        self.title_de = data['lang'][lang]["title"]
                        self.subtitle_de = data['lang'][lang]["subtitle"]
                        self.desc_de = data['lang'][lang]["desc"]
                        self.body_de = BlogPost.__patch_html(MD.convert(body))
                    case _:
                        logger.error(
                            f"unknown language '{lang}' in meta file for '{self}'")
        self.date = data["date"]
        self.update = data["update"]
        self.featured = data["featured"]
        self.public = data["public"]
        # NOTE: thumbnail is optional
        if "thumbnail" in data:
            self.thumbnail = data["thumbnail"]
        # NOTE: category is optional
        if "category" in data:
            try:
                category: Category = Category.objects.get(
                    slug=data['category'])
            except Category.DoesNotExist:
                category = Category.objects.create(
                    name=data['category'], slug=data['category'])
            self.category = category
        self.save()
        for keyword in data["keywords"]:
            try:
                self.keywords.add(Keyword.objects.get(slug=keyword))
            except Keyword.DoesNotExist:
                self.keywords.create(
                    slug=keyword, text_en=keyword, text_de=keyword)
        self.save()

    def get_langs(self) -> dict[str, bool]:
        """
        get available languages
        """
        # SECURITY:
        # If someone could inject the langs field, literal_eval will evaluate to
        # any literal Python value. This should not be a concern.
        # If we would use a regular eval here, an attacker who controls one of
        # the `langs` of a BlogPost could run arbitrary Python code.
        try:
            # literal_eval is safe because it only parses literals, not any kind
            # of python expression. If the given str is not a valid literal,
            # ValueError will be raised
            langs = ast.literal_eval(str(self.langs))
            return langs
        except ValueError as e:
            logger.error(
                f"could not safely evaluate 'langs' for '{self}': {e}")
            raise e

    def set_langs(self, langs: dict[str, bool]):
        """
        set available languages
        """
        self.langs = langs.__repr__()

    @ classmethod
    def sync_with_fs(cls):
        """
        Sync all Blog Posts with the filesystem.

        Caution: Will delete all Blog Posts
        """
        # logger.name = logger.name + ".sync_with_fs"

        # delete all existing objects
        BlogPost.objects.all().delete()

        # check if the DATA_DIR is OK
        data_dir = pathlib.Path(cls.DATA_DIR)
        if not data_dir.exists():
            logger.error(f"'{cls.DATA_DIR} does not exist'")
        if not data_dir.is_dir():
            logger.error(f"'{cls.DATA_DIR} is not a directory'")

        files = [f for f in os.listdir(data_dir) if (
            data_dir.joinpath(f)).is_file()]

        # find the meta file
        regex = r"^(.*)\.toml"

        # filepath, slug
        files = [[f, ""] for f in files]
        for file in files:
            # parse file name
            try:
                matches = re.match(regex, file[0])
                if matches is None:
                    # file is not a toml / meta file
                    files.remove(file)
                    continue
                else:
                    current_lang = matches.group(1)
                    file[1] = matches.group(1)

                obj = BlogPost(slug=file[1])
                obj.sync_file()
                obj.regenerate()
            except Exception as e:
                logger.error(f"Could not create BlogPost for '{file[1]}': {e}")
                files.remove(file)

    class Meta:
        verbose_name = _("blog post")
        verbose_name_plural = _("blog posts")
