import json
import logging
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from aiohttp import web

from .config import Config
from .encoder import Encoder
from .Events import EventBus, Events
from .Singleton import Singleton
from .Utils import arg_converter, init_class

LOG = logging.getLogger("presets")

DEFAULT_PRESETS: list[dict[int, dict[str, str | bool]]] = [
    {
        "id": "3e163c6c-64eb-4448-924f-814b629b3810",
        "name": "default",
        "default": True,
    },
    {
        "id": "5bf9c42b-8852-468a-99f5-915622dfba25",
        "name": "Best video and audio",
        "cli": "--format 'bv+ba/b'",
        "default": True,
    },
    {
        "id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b",
        "name": "1080p H264/m4a or best available",
        "cli": "-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
        "default": True,
    },
    {
        "id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e",
        "name": "720p h264/m4a or best available",
        "cli": "-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
        "default": True,
    },
    {
        "id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330",
        "name": "Audio only",
        "cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'",
        "default": True,
    },
    {
        "id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f60",
        "name": "yt-dlp info reader plugin",
        "description": 'This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o "%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary',
        "folder": "youtube",
        "template": "%(channel)s %(channel_id|Unknown_id)s/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(extractor)s-%(id)s].%(ext)s",
        "cli": "--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata",
        "default": True,
    },
]


@dataclass(kw_only=True)
class Preset:
    id: str = field(default_factory=lambda: str(uuid.uuid4()))
    """The id of the preset."""

    name: str
    """The name of the preset."""

    description: str = ""
    """The description of the preset."""

    folder: str = ""
    """The default download folder to use if non is given."""

    template: str = ""
    """The default template to use if non is given."""

    cookies: str = ""
    """The default cookies to use if non is given."""

    cli: str = ""
    """command options for yt-dlp."""

    default: bool = False
    """If True, the preset is a default preset."""

    def serialize(self) -> dict:
        return self.__dict__

    def json(self) -> str:
        return Encoder().encode(self.serialize())

    def get(self, key: str, default: Any = None) -> Any:
        return self.serialize().get(key, default)


class Presets(metaclass=Singleton):
    """
    This class is used to manage the presets.
    """

    _items: list[Preset] = []
    """The list of presets."""

    _instance = None
    """The instance of the class."""

    _default: list[Preset] = []

    def __init__(self, file: str | Path | None = None, config: Config | None = None):
        Presets._instance = self

        config = config or Config.get_instance()

        self._file: Path = Path(file) if file else Path(config.config_path).joinpath("presets.json")

        if self._file.exists() and "600" != self._file.stat().st_mode:
            try:
                self._file.chmod(0o600)
            except Exception:
                pass

        for i, preset in enumerate(DEFAULT_PRESETS):
            try:
                self.validate(preset)
                self._default.append(init_class(Preset, preset))
            except Exception as e:
                LOG.error(f"Failed to parse default preset ':{i}'. '{e!s}'.")
                continue

        def event_handler(_, __):
            msg = "Not implemented"
            raise Exception(msg)

        EventBus.get_instance().subscribe(Events.PRESETS_ADD, event_handler, f"{__class__.__name__}.add")

    @staticmethod
    def get_instance() -> "Presets":
        """
        Get the instance of the class.

        Returns:
            Presets: The instance of the class

        """
        if not Presets._instance:
            Presets._instance = Presets()

        return Presets._instance

    async def on_shutdown(self, _: web.Application):
        pass

    def attach(self, _: web.Application):
        """
        Attach the class to the aiohttp application.

        Args:
            _ (web.Application): The aiohttp application.

        Returns:
            None

        """
        self.load()

    def get_all(self) -> list[Preset]:
        """Return the items."""
        return self._default + self._items

    def load(self) -> "Presets":
        """
        Load the items.

        Returns:
            Presets: The current instance.

        """
        has: int = len(self._items)
        self.clear()

        if not self._file.exists() or self._file.stat().st_size < 10:
            return self

        try:
            LOG.info(f"{'Reloading' if has else 'Loading'} '{self._file}'.")
            presets: dict = json.loads(self._file.read_text())
        except Exception as e:
            LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
            return self

        if not presets or len(presets) < 1:
            return self

        need_save = False

        for i, preset in enumerate(presets):
            try:
                if "id" not in preset:
                    preset["id"] = str(uuid.uuid4())
                    need_save = True

                if preset.get("format"):
                    if not preset.get("cli"):
                        preset.update({"cli": f"--format {preset['format']}"})
                    else:
                        preset["cli"] = f"--format '{preset['format']}'\n" + preset["cli"]

                    preset["cli"] = str(preset["cli"]).strip()

                    preset.pop("format")
                    need_save = True

                preset: Preset = init_class(Preset, preset)

                self._items.append(preset)
            except Exception as e:
                LOG.error(f"Failed to parse '{self._file}:{i}'. '{e!s}'.")
                continue

        if need_save:
            LOG.info(f"Saving '{self._file}'.")
            self.save(self._items)

        return self

    def clear(self) -> "Presets":
        """
        Clear all items.

        Returns:
            Presets: The current instance.

        """
        if len(self._items) < 1:
            return self

        self._items.clear()

        return self

    def validate(self, item: Preset | dict) -> bool:
        """
        Validate the item.

        Args:
            item (Preset|dict): The item to validate.

        Returns:
            bool: True if valid

        Raises:
            ValueError: If the item is not valid.

        """
        if not isinstance(item, dict):
            if not isinstance(item, Preset):
                msg = f"Unexpected '{type(item).__name__}' type was given."
                raise ValueError(msg)  # noqa: TRY004

            item = item.serialize()

        if not item.get("id"):
            msg = "No id found."
            raise ValueError(msg)

        if not item.get("name"):
            msg = "No name found."
            raise ValueError(msg)

        if item.get("cli"):
            try:
                arg_converter(args=item.get("cli"))
            except Exception as e:
                msg = f"Invalid command options for yt-dlp. '{e!s}'."
                raise ValueError(msg) from e

        return True

    def save(self, items: list[Preset | dict]) -> "Presets":
        """
        Save the items.

        Args:
            items (list[Preset]): The items to save.

        Returns:
            Presets: The current instance.

        """
        for i, preset in enumerate(items):
            try:
                if not isinstance(preset, Preset):
                    preset: Preset = init_class(Preset, preset)
                    items[i] = preset
            except Exception as e:
                LOG.error(f"Failed to save item '{i}' due to parsing error. '{e!s}'.")
                continue

            try:
                self.validate(preset)
            except ValueError as e:
                LOG.error(f"Failed to validate item '{i}: {preset.name}'. '{e}'.")
                continue

        try:
            self._file.write_text(
                json.dumps(obj=[preset.serialize() for preset in items if preset.default is False], indent=4)
            )

            LOG.info(f"Saved '{self._file}'.")
        except Exception as e:
            LOG.error(f"Failed to save '{self._file}'. '{e!s}'.")

        return self

    def has(self, id_or_name: str) -> bool:
        """
        Check if the item exists by id or name.

        Args:
            id_or_name (str): The id or name of the item.

        Returns:
            bool: True if exists, False otherwise.

        """
        return self.get(id_or_name) is not None

    def get(self, id_or_name: str) -> Preset | None:
        """
        Get the item by id or name.

        Args:
            id_or_name (str): The id or name of the item.

        Returns:
            Preset|None: The item if found, None otherwise.

        """
        if not id_or_name:
            return None

        for preset in self.get_all():
            if id_or_name not in (preset.id, preset.name):
                continue

            return preset

        return None
