from pathlib import Path
import logging
import shutil
from collections.abc import Iterable
import signal
import time
from concurrent.futures import ThreadPoolExecutor, as_completed, Future
import threading
import inspect
import signal
import threading

from typing import Union, Dict, Iterable, List, Set

from ankipan import TextSegment, SourcePath, Client, \
                    Deck, Card, Reader, AnkiManager, PersistentStorage, \
                    COLLECTIONS_DIR, OngoingTask, OngoingTasks, GPTQueue, HtmlWrappers, HTML_TEMPLATE_DIR
from ankipan.flashcard_fields import get_flashcard_field_registry
from ankipan.util import *


logger = logging.getLogger(__name__)

class Collection(PersistentStorage):
    def __init__(self,
                 name: str,
                 learning_lang: str = None,
                 native_lang: str = None,
                 *,
                 secondary_native_langs: List[str] = None,
                 use_server_gpt: bool = True,
                 force_overwrite = False,
                 data_dir = COLLECTIONS_DIR):
        """
        Load new or existing collection.
        A collection can hold 0 to n decks.
        A deck can hold 1 to n flashcards.
        Flashcards have 1 to n fields, which refers to information on the backside (dictionary definitions, example sentences etc.)

        Parameters
        ----------
        name : Name of collection.
            Creates new collection for new names, loads existing collection for existing names.
        learning_lang : Name of language the user wants to learn.
        native_lang : Native language of the user for translations and explanations.

        """
        self.name = name
        self.data_dir = data_dir
        self.collection_dir = data_dir / name
        self.collection_dir.mkdir(parents=True, exist_ok=True)

        super().__init__(self.collection_dir / "metadata.json")

        self._learning_lang = None
        self._native_lang = None
        self._flashcard_fields = []
        self._example_sentence_sources: Dict[str, float] = {}
        self._n_example_sentences: int = 10
        self._known_words = []
        self._ignoring_words = []
        self._use_server_gpt = use_server_gpt
        self._secondary_native_langs = secondary_native_langs if secondary_native_langs else []

        self._register_persistent_attribute("_learning_lang")
        self._register_persistent_attribute("_native_lang")
        self._register_persistent_attribute("_flashcard_fields")
        self._register_persistent_attribute(
            "_example_sentence_sources",
            serialize=lambda xs: {str(k): float(v) for k, v in (xs or {}).items()},
            deserialize=lambda xs: Collection._deserialize_example_sentence_sources(xs),
        )
        self._register_persistent_attribute("_n_example_sentences")
        self._register_persistent_attribute("_known_words")
        self._register_persistent_attribute("_ignoring_words")
        self._register_persistent_attribute("_secondary_native_langs")

        self._register_persistent_attribute("_use_server_gpt")
        if self.collection_dir.exists() and force_overwrite:
            shutil.rmtree(self.collection_dir)
        if not self.json_path.exists():
            if not (learning_lang and native_lang):
                raise RuntimeError("learning_lang and native_lang kwargs required when initializing new collection")
            self._learning_lang = learning_lang
            self._native_lang = native_lang
        else:
            if learning_lang or native_lang:
                raise RuntimeError(f'Initializing existing database "{name}", specified kwargs would be ignored')
            self._load_from_storage()

        if self.learning_lang not in Reader.SUPPORTED_LEARNING_LANGUAGES:
            raise RuntimeError(
                f"learning_lang '{self.learning_lang}' currently unsupported. "
                f"Supported languages: {sorted(Reader.SUPPORTED_LEARNING_LANGUAGES)}"
            )

        self.decks: Dict[str, List[Card]] = {}
        decks_dir = self.collection_dir / "decks"
        if decks_dir.exists():
            for deck_path in decks_dir.iterdir():
                if not deck_path.is_dir():
                    continue
                deck_name = deck_path.stem
                cards = []
                for card_path in deck_path.iterdir():
                    if not card_path.is_file():
                        continue
                    card = Card(card_path.stem, json_path=card_path)
                    cards.append(card)
                if cards:
                    cards.sort(key=lambda c: (c.index is None, c.index if c.index is not None else c.word))
                    # If indexes are missing (legacy decks), assign sequentially to preserve current order going forward.
                    missing_idx = any(c.index is None for c in cards)
                    if missing_idx:
                        for idx, card in enumerate(cards):
                            if card.index is None:
                                card.set_index(idx)
                    self.decks[deck_name] = cards
        else:
            decks_dir.mkdir(parents=True, exist_ok=True)

        self.reader = Reader(self.learning_lang)
        self.anki_manager = AnkiManager()
        # pass permanent variables as reference so fields can work with them
        self.gpt_queue = GPTQueue()
        self.flashcard_field_registry = get_flashcard_field_registry(self, gpt_queue=self.gpt_queue)

    @property
    def cards(self):
        return {card.word: card for cards in self.decks.values() for card in cards}

    def print_available_flashcard_fields(self):
        for field_name, field_collector in self.flashcard_field_registry.items():
            if len(field_collector.exposed_fields) == 1:
                print(f"'{field_collector.exposed_fields[0]}',")
            else:
                if len(field_collector.exposed_fields) > 0:
                    print(f"\n{field_name}:")
                    for sub_field in field_collector.exposed_fields:
                        print(f"    '{sub_field}',")

    def print_available_example_sentence_sources(self, source_path = None, max_items = 100) -> Dict[str, Callable[..., Any]]:
        avail = self.flashcard_field_registry['example_sentences'].available_sources(source_path)
        def _truncate_list(values, max_items):
            values = list(values)
            if len(values) <= max_items:
                return values
            return values[:max_items//2] + ["..."] + values[-max_items//2:]
        if source_path is None and self._example_sentence_sources:
            print("Current preferred sources (path -> weight):")
            for path, weight in sorted(self._example_sentence_sources.items()):
                print(f"  {path}: {weight:.2f}")
        if isinstance(avail, str):
            print(avail)
            return
        if isinstance(avail, list):
            print(_truncate_list(avail, max_items))
            return
        # Handle single SourceNode (returned when a concrete source_path is passed)
        if hasattr(avail, "children") and hasattr(avail, "lemma_counts"):
            children = getattr(avail, "children", {}) or {}
            path = "/".join(getattr(avail, "path", ())) or "<root>"
            print(f"{path}: children={_truncate_list(children.keys(), max_items)}")
            lemma_counts = getattr(avail, "lemma_counts", {}) or {}
            if lemma_counts:
                top = sorted(lemma_counts.items(), key=lambda x: x[1], reverse=True)
                top = _truncate_list([f"{w}:{n}" for w,n in top], max_items=max_items)
                print(f"  top {max_items} lemmas={top}")
            return
        for server, root in avail.items():
            children = getattr(root, "children", {}) or {}
            print(f"{server}: children={_truncate_list(children.keys())}", max_items)
            lemma_counts = getattr(root, "lemma_counts", {}) or {}
            if lemma_counts:
                top = sorted(lemma_counts.items(), key=lambda x: x[1], reverse=True)
                top = _truncate_list([f"{w}:{n}" for w,n in top], max_items=max_items)
                print(f"  top {max_items} lemmas={top}")

    def set_use_server_gpt(self, value: bool):
        assert isinstance(value, bool)
        self._use_server_gpt = value

    def _modify_list(self, target_list, items: List[Any], warning_msg: str = None, add = False, deduplicate = True):
        if add:
            for item in items:
                if (not deduplicate) or (item not in target_list):
                    target_list.append(item)
                else:
                    if warning_msg:
                        logger.warning(warning_msg.format(item))
        else:
            target_list.clear()
            if deduplicate:
                seen = set()
                for item in items:
                    if item not in seen:
                        target_list.append(item)
                        seen.add(item)
            else:
                target_list.extend(items)

    def set_flashcard_fields(self,
                             field_names: List[str],
                             add = False):
        invalid_entries = [
            field_name for field_name in field_names
            if field_name not in self.flashcard_field_registry.valid_field_names
        ]
        if invalid_entries:
            raise RuntimeError(f'Invalid flashcard fields specified: {invalid_entries}\nPlease check available fields with `Collection.print_available_flashcard_fields`')
        self._modify_list(
            self._flashcard_fields,
            field_names,
            warning_msg = 'Flashcard Field "{}" already in Collection.flashcard_fields, skipping...',
            add = add
        )
        self._flashcard_fields = sorted(set(self._flashcard_fields))
        self.save()

    @staticmethod
    def _deserialize_example_sentence_sources(data):
        """
        Backward-compat: accept legacy list/tuple, convert to weight=0.3 per path.
        """
        if not data:
            return {}
        if isinstance(data, dict):
            return {str(k): float(v) for k, v in data.items()}
        if isinstance(data, (list, tuple, set)):
            return {str(SourcePath(x)): 0.3 for x in data}
        return {}

    @property
    def example_sentence_source_weights(self) -> Dict[str, float]:
        return dict(self._example_sentence_sources)

    @property
    def example_sentence_source_paths(self) -> List[SourcePath]:
        return [SourcePath(p) for p in self._example_sentence_sources.keys()]

    @property
    def example_sentences_n_sentences(self) -> int:
        try:
            return max(1, int(self._n_example_sentences))
        except Exception:
            return 10

    def set_n_example_sentences(self, n: int):
        """Persist number of example sentences to request per lemma (defaults to 10)."""
        self._n_example_sentences = max(1, int(n))
        self.save()

    def set_example_sentence_sources(self, example_sentence_sources: Union[Dict[str, float], List[str]], add = False, default_weight: float = 0.3):
        """
        Path starts with the server name, then specifies path to the target source.
        Tracks example sentence sources that the user would like to favor when studying (e.g. particular youtubers, movies etc.)

        Parameters
        ----------
        example_sentence_sources : list of strings
            List of sources to collect example sentences from.
            When flashcards are generated, example sentences for all root categories from all paths will be considered, where specified sources will be prioritized and highlighted.

        Example
        -------
            If we are interested in the two youtubers "ankipan_default/youtube/sushiramen" and "ankipan_default/youtube/hajimesyacho", then flashcards will contain a "youtube" example sentence section,
            where the specified youtubers are included alongside other sources from youtube (~evenly split among specified youtubers, ~50%-50% split between specified and other youtubers).
            Not specifying any preference will cause the youtube example sentence section to only contain random youtubers with suitable sentences (for more details see `get_sentences_for_lemma` in ankipan_db).
        """
        if isinstance(example_sentence_sources, dict):
            raw_items = example_sentence_sources.items()
        elif isinstance(example_sentence_sources, (list, tuple, set)):
            raw_items = [(p, default_weight) for p in example_sentence_sources]
        else:
            raise RuntimeError(f"Unsupported type for example_sentence_sources: {type(example_sentence_sources)}")

        def _clamp(w):
            try:
                return max(0.0, min(1.0, float(w)))
            except Exception:
                return default_weight

        incoming = {str(SourcePath(p)): _clamp(w) for p, w in raw_items if p is not None}
        invalid_source_paths = Client.get_invalid_source_paths(
            self.learning_lang,
            [SourcePath(source_path) for source_path in incoming.keys()],
        )
        if invalid_source_paths:
            raise RuntimeError(
                f"Some of the specified example sentence source paths are invalid: {invalid_source_paths}\n"
                f"Find valid paths with (collection.print_available_example_sentence_sources(<optional_path>))"
            )

        if add:
            merged = dict(self._example_sentence_sources)
            merged.update(incoming)
            self._example_sentence_sources = merged
        else:
            self._example_sentence_sources = incoming
        self.save()

    def set_known_words(self, words: Iterable[str], add=False):
        self._modify_list(
            self._known_words,
            words,
            warning_msg='Known word "{}" already in Collection.known_words, skipping...',
            add=add,
        )

    def set_ignoring_words(self, words: Iterable[str], add=False):
        self._modify_list(
            self._ignoring_words,
            words,
            warning_msg='Ignoring word "{}" already in Collection.ignoring_words, skipping...',
            add=add,
        )

    def extract_lemmas(self,
                        paths: Union[Union[str, Path], Iterable[Union[str, Path]]] = None,
                        *,
                        source_path: str = None,
                        string: str = None,
                        lemma_counts: Dict[str, int] = None) -> Deck:
        """
        Collect words from specified source.

        Parameters
        ----------
        path: Path to a file or directory with textfiles to be added to the collection
            Uses segment lemma parsing from reader.py to parse source files into wordcount dictionary

        kwargs (only consiered if path is not provided):
            source_path: Path of source in db to fetch lemmas from (see Collection.print_available_example_sentence_sources(<optional_path>), does not require segment parsing)
            string: String to be parsed directly without reading a file
            lemma_counts: Dict of words and lemma_counts, directly adopted as Deck object

        """
        if not (isinstance(paths, list) or isinstance(paths, set)):
            if isinstance(paths, str) or isinstance(paths, Path):
                paths = [paths]
            elif not paths == None:
                raise RuntimeError(f'Unknown type passed as path: {paths}')
        deck = Deck(self.learning_lang, self.native_lang,
                    learning_collection_words = set(self.cards.keys()),
                    known_collection_words = self.known_words,
                    ignoring_collection_words = self.ignoring_words,
                    example_sentence_fields = self.example_sentence_source_paths)
        if paths:
            for path in paths:
                deck.add(path=path)
        elif string:
            deck.add(string=string)
        elif lemma_counts:
            deck.add(lemma_counts=lemma_counts)
        elif source_path:
            source = self.flashcard_field_registry['example_sentences'].available_sources(source_path)
            lemma_counts = source.lemma_counts
            if not lemma_counts:
                raise RuntimeError(f'No lemma counts received for "{source_path}", please check server.')
            deck.source_words.update(lemma_counts)
        return deck

    def add_deck(self,
            deck: Deck,
            deck_name: str):
        """
        Add new deck from new words in Deck to current collection.
        Changes made to the new words and known words in the Deck object are adopted into the collection.

        Parameters
        ----------
        words: Words from Deck
        deck_name: Name of source you are adding, e.g. movie or series title

        """

        if not deck_name:
            raise RuntimeError('deck_name is a mandatory field')

        existing_cards = self.decks.get(deck_name, [])
        max_existing_idx = max((c.index for c in existing_cards if c.index is not None), default=len(existing_cards) - 1)
        next_index = max_existing_idx + 1

        self.set_known_words(set(deck.known_words) - set(self.known_words), add=True)
        self.set_ignoring_words(set(deck.ignoring_words) - set(self.ignoring_words), add=True)
        self.decks.setdefault(deck_name, [])
        for lemma in deck.new_words:
            if any(card.word == lemma for card in self.decks[deck_name]):
                logger.info('Word "%s" already present in deck "%s", skipping.', lemma, deck_name)
                continue
            deck_example_sentences = []
            for file in deck.added_files:
                example_sentences = [
                    TextSegment(
                        main_index = index - max(0, index-1),
                        text_segments = [seg.text for seg in file.segment_records[max(0, index-1): index+2]],
                        word = word,
                        source_name = file.path.stem if file.path else f'{deck_name} {index+1}/{len(file.segment_records)}'
                    ) for word, indices in file.lemma_word_index_mapping.get(lemma, {}).items() for index in indices
                ]
                deck_example_sentences.extend(example_sentences)
            self.decks[deck_name].append(Card(lemma,
                                              deck_example_sentences = deck_example_sentences,
                                              json_path = self.data_dir / self.name / 'decks' / deck_name / f'{lemma}.json'))
            self.decks[deck_name][-1].set_index(next_index)
            next_index += 1
        # Keep deck order stable by index
        self.decks[deck_name].sort(key=lambda c: (c.index is None, c.index if c.index is not None else c.word))
        self.save()

    def remove_deck(self, deck_name: str):
        # TODO: consider caching mechanism so downloaded info isn't destroyed
        if deck_name not in self.decks: raise RuntimeError(f'Deck "{deck_name}" not present in collection')
        for card in self.decks.pop(deck_name):
            shutil.remove(card.json_path)

    def estimate_known_words(self):
        estimate_known_words(self.flashcard_field_registry['example_sentences'].available_sources, self.known_words)
        self.save()

    def get_cards_with_missing_data(self, cards, field_name, force_update = False) -> List[Card]:
        return cards if force_update else [card for card in cards if field_name not in card.flashcard_fields]

    # TODO: ignores existing sources and overwrites everything for some reason
    def trigger_fields(self,
                       deck_name: str,
                       force_update: bool = False,
                       fields_to_update: List[str] | None = None):
        target_fields = self.flashcard_fields
        if fields_to_update is not None:
            invalid = [f for f in fields_to_update if f not in self.flashcard_fields]
            if invalid:
                raise RuntimeError(f"Unknown fields in fields_to_update: {invalid}")
            target_fields = [f for f in self.flashcard_fields if f in set(fields_to_update)]
        for exposed_field_name in target_fields:
            kwargs = {"force_update": force_update}
            field_name = SourcePath(exposed_field_name)[0]
            field_cls = self.flashcard_field_registry[field_name]
            if not field_cls.implements_trigger:
                continue
            if '/' in exposed_field_name:
                kwargs["field_path"] = SourcePath(exposed_field_name)
            cards_to_trigger = self.get_cards_with_missing_data(
                self.decks[deck_name],
                str(kwargs.get("field_path", exposed_field_name)),
                force_update=force_update
            )
            if not cards_to_trigger:
                logger.info(f'No cards need a trigger for {kwargs.get("field_path", exposed_field_name)}')
                continue
            triggered = field_cls.trigger_fields(cards_to_trigger, **kwargs)
            if triggered is False:
                logger.info(
                    'Skipped trigger for field "%s" (no applicable sources).',
                    kwargs.get("field_path", exposed_field_name),
                )
                continue
            logger.info(f'Triggered computation of field "{kwargs.get("field_path", exposed_field_name)}" for {len(cards_to_trigger)} card(s).')
        self.save()

    def collect_field_data(self,
                           deck_name: str,
                           force_update: bool = False,
                           poll_interval: float = 60.0,
                           fields_to_update: List[str] | None = None):
        if not self.use_server_gpt:
            self.gpt_queue.start_thread()
        stop_event = threading.Event()
        interrupted = False
        previous_sigint_handler = signal.getsignal(signal.SIGINT)

        def _handle_sigint(signum, frame):
            print("[collect_field_data] Interrupting...")
            stop_event.set()
            import _thread
            _thread.interrupt_main()

        signal.signal(signal.SIGINT, _handle_sigint)

        def _collect_non_trigger(cards, exposed_field_name):
            field_cls = self.flashcard_field_registry[SourcePath(exposed_field_name)[0]]
            collect_kwargs = {"force_update": force_update}
            if "stop_event" in inspect.signature(field_cls.collect_data).parameters:
                collect_kwargs["stop_event"] = stop_event
            field_cls.collect_data(cards, exposed_field_name, **collect_kwargs)

        try:
            trigger_jobs = []
            trigger_progress = {}
            non_trigger_jobs = []
            errors: list[str] = []
            target_fields = self.flashcard_fields
            if fields_to_update is not None:
                invalid = [f for f in fields_to_update if f not in self.flashcard_fields]
                if invalid:
                    raise RuntimeError(f"Unknown fields in fields_to_update: {invalid}")
                target_fields = [f for f in self.flashcard_fields if f in set(fields_to_update)]
            # Partition work by trigger requirement
            for exposed_field_name in target_fields:
                field_cls = self.flashcard_field_registry[SourcePath(exposed_field_name)[0]]
                cards = list(self.decks[deck_name])
                if field_cls.implements_trigger:
                    missing_trigger = []
                    pending_cards = []
                    for card in cards:
                        val = card.flashcard_fields.get(exposed_field_name)
                        if isinstance(val, (OngoingTask, OngoingTasks)):
                            pending_cards.append(card)
                        elif not isinstance(val, HtmlWrappers.CardSection):
                            missing_trigger.append(card.word)
                    if missing_trigger:
                        raise RuntimeError(
                            f'Field "{exposed_field_name}" has cards with missing triggered fields, '
                            f"run collection.trigger_fields first: {missing_trigger}"
                        )
                    if pending_cards:
                        trigger_jobs.append((exposed_field_name, pending_cards))
                        trigger_progress[exposed_field_name] = {
                            "initial": len(pending_cards),
                            "last_remaining": len(pending_cards),
                        }
                else:
                    cards_to_collect = self.get_cards_with_missing_data(cards, exposed_field_name, force_update=force_update)
                    if cards_to_collect:
                        non_trigger_jobs.append((exposed_field_name, cards_to_collect))
            if not trigger_jobs and not non_trigger_jobs:
                return
            executor = None
            futures = []
            try:
                if non_trigger_jobs:
                    max_workers = max(1, min(len(non_trigger_jobs), 5))
                    executor = ThreadPoolExecutor(max_workers=max_workers)
                    future_to_field = {}
                    for exposed_field_name, cards in non_trigger_jobs:
                        future = executor.submit(_collect_non_trigger, cards, exposed_field_name)
                        future_to_field[future] = exposed_field_name
                    for future in as_completed(future_to_field):
                        exposed_field_name = future_to_field[future]
                        try:
                            future.result()
                        except Exception as exc:  # collect all non-trigger errors
                            logger.exception("[collect_field_data] Field '%s' failed during non-trigger collect", exposed_field_name)
                            errors.append(f"{exposed_field_name}: {exc}")
            finally:
                if executor:
                    executor.shutdown(wait=True, cancel_futures=False)
            if errors:
                bullet_list = "\n".join(f"- {err}" for err in errors)
                raise RuntimeError(f"[collect_field_data] Errors encountered:\n{bullet_list}")
            summary_lines = []
            for exposed_field_name, cards in trigger_jobs:
                field_cls = self.flashcard_field_registry[SourcePath(exposed_field_name)[0]]
                collect_kwargs = {"force_update": force_update}
                if "stop_event" in inspect.signature(field_cls.collect_data).parameters:
                    collect_kwargs["stop_event"] = stop_event
                remaining_before = len(field_cls.get_cards_with_ongoing_task(cards, exposed_field_name))
                try:
                    changed = field_cls.collect_data(cards, exposed_field_name, **collect_kwargs)
                except Exception as exc:
                    logger.exception("[collect_field_data] Field '%s' failed during trigger collect", exposed_field_name)
                    initial = trigger_progress.get(exposed_field_name, {}).get("initial", len(cards))
                    errors.append(
                        f'{exposed_field_name}: initial={initial}, remaining_before={remaining_before}, changed_this_iter={locals().get("changed", "n/a")} err={exc}'
                    )
                    continue
                remaining = len(field_cls.get_cards_with_ongoing_task(cards, exposed_field_name))
                initial = trigger_progress[exposed_field_name]["initial"]
                collected_total = initial - remaining
                collected_iter = max(0, remaining_before - remaining)
                trigger_progress[exposed_field_name]["last_remaining"] = remaining
                summary_lines.append(
                    f"{exposed_field_name}: collected_iter={collected_iter} collected_total={collected_total} remaining={remaining} initial={initial}"
                )
            if summary_lines:
                logger.info("[collect_field_data] Trigger summary:\n%s", "\n".join(f"- {line}" for line in summary_lines))
            if errors:
                bullet_list = "\n".join(f"- {err}" for err in errors)
                raise RuntimeError(f"[collect_field_data] Errors encountered:\n{bullet_list}")

        except KeyboardInterrupt:
            stop_event.set()
            interrupted = True
            raise
        finally:
            if not interrupted:
                self.save()
            signal.signal(signal.SIGINT, previous_sigint_handler)
            if self.use_server_gpt:
                self.gpt_queue.stop_thread()


    # TODO: add "full_overwrite" kwarg, which bulk-removes and readds card instead of updating one by one (faster)
    def sync_with_anki(self, deck_name: str, overwrite: bool = False, n_cards = None):
        """
        Sync collection data with Anki via AnkiConnect.
        Requires Anki (and AnkiConnect) running and logged in.
        https://apps.ankiweb.net/
        https://ankiweb.net/shared/info/2055492159/
        """
        deck = self.decks[deck_name] if not n_cards else self.decks[deck_name][:n_cards]

        required = set(self.flashcard_fields)
        cards_with_missing_sections: dict[str, list[str]] = {}
        for card in deck:
            have = set(field_name for field_name, field in getattr(card, "flashcard_fields", {}).items() if not isinstance(field, OngoingTask))
            for field in (required - have):
                cards_with_missing_sections.setdefault(field, []).append(card.word)

        if cards_with_missing_sections:
            lines = []
            for field, words in sorted(cards_with_missing_sections.items()):
                lines.append(f"- {field}: {len(words)} missing → [{', '.join(words)}]")
            raise RuntimeError(
                "Some cards are missing data for fields defined in Collection.flashcard_fields; "
                "run Collection.trigger_fields(<deck_name>) and Collection.collect_fields(<deck_name>) first.\n" + "\n".join(lines)
            )

        if deck:
            css_files = sorted(HTML_TEMPLATE_DIR.glob("*.css"))
            js_files = sorted(HTML_TEMPLATE_DIR.glob("*.js"))
            assets = {
                "css_filename": "_ankipan.css",
                "css_content": "\n\n".join(path.read_text(encoding="utf-8") for path in css_files),
                "js_filename": "_ankipan.js",
                "js_content": "\n\n".join(path.read_text(encoding="utf-8") for path in js_files),
            }
            self.anki_manager.sync_media_files([
                (assets["css_filename"], assets["css_content"]),
                (assets["js_filename"], assets["js_content"]),
            ])
            logger.info("Syncing Anki for deck=%s cards=%d", deck_name, len(deck))
            print("Syncing Anki for words:", [c.word for c in deck])
            self.anki_manager.sync_deck(deck_name, deck, overwrite=overwrite, field_order=self.flashcard_fields)
            self.anki_manager.sync()
        self.save()

    def save(self):
        """
        Write all collection data to `self.data_dir`.

        """
        logger.info(f'Saving collection data to {self.collection_dir.absolute()}')
        self._save_storage()
        for card in self.cards.values():
            card.save()

    # TODO: Implement safety mechanism to not accidentally lose massive amounts of data
    # TODO: Implement function to recreate database based on current anki database state in case of loss
    def delete_collection(self, name):
        """
        Delete collection from database
        Currently ignores whether collection is present in database or not

        """
        if input('Are you sure? type "yes"') == 'yes':
            shutil.rmtree(self.data_dir / name)

    def __str__(self):
        deck_overview = f'Decks ({len(self.decks)}):\n'
        for deck_name, cards in self.decks.items():
            deck_overview += f'  {deck_name}: {len(cards)} cards\n'
        return super().__str__() + '\n' + deck_overview
