"""This module contains fixtures and other helpful functions for the tests."""

import pathlib
import copy
import typing
import itertools
import filecmp
from typing import Optional
import os
import shutil

import pypdf
import jinja2
import pytest
import pydantic
import pydantic_extra_types.phone_numbers as pydantic_phone_numbers

from rendercv import data_models as dm
import rendercv.renderer as r

# RenderCV is being tested by comparing the output to reference files. Therefore,
# reference files should be updated when RenderCV is updated in a way that changes
# the output. Setting update_testdata to True will update the reference files with
# the latest RenderCV. This should be done with caution, as it will overwrite the
# reference files with the latest output.
update_testdata = False

# copy sample entries from docs/update_rendercv_files.py:
education_entry_dictionary = {
    "institution": "Boğaziçi University",
    "location": "Istanbul, Turkey",
    "degree": "BS",
    "area": "Mechanical Engineering",
    "start_date": "2015-09",
    "end_date": "2020-06",
    "highlights": [
        "GPA: 3.24/4.00 ([Transcript](https://example.com))",
        "Awards: Dean's Honor List, Sportsperson of the Year",
    ],
}

experience_entry_dictionary = {
    "company": "Some Company",
    "location": "TX, USA",
    "position": "Software Engineer",
    "start_date": "2020-07",
    "end_date": "2021-08-12",
    "highlights": [
        (
            "Developed an [IOS application](https://example.com) that has received"
            " more than **100,000 downloads**."
        ),
        "Managed a team of **5** engineers.",
    ],
}

normal_entry_dictionary = {
    "name": "Some Project",
    "location": "Remote",
    "date": "2021-09",
    "highlights": [
        "Developed a web application with **React** and **Django**.",
        "Implemented a **RESTful API**",
    ],
}

publication_entry_dictionary = {
    "title": (
        "Magneto-Thermal Thin Shell Approximation for 3D Finite Element Analysis of"
        " No-Insulation Coils"
    ),
    "authors": ["J. Doe", "***H. Tom***", "S. Doe", "A. Andsurname"],
    "date": "2021-12-08",
    "journal": "IEEE Transactions on Applied Superconductivity",
    "doi": "10.1109/TASC.2023.3340648",
}

one_line_entry_dictionary = {
    "label": "Programming",
    "details": "Python, C++, JavaScript, MATLAB",
}

bullet_entry_dictionary = {
    "bullet": "This is a bullet entry.",
}


@pytest.fixture
def publication_entry() -> dict[str, str | list[str]]:
    """Return a sample publication entry."""
    return copy.deepcopy(publication_entry_dictionary)


@pytest.fixture
def experience_entry() -> dict[str, str]:
    """Return a sample experience entry."""
    return copy.deepcopy(experience_entry_dictionary)


@pytest.fixture
def education_entry() -> dict[str, str]:
    """Return a sample education entry."""
    return copy.deepcopy(education_entry_dictionary)


@pytest.fixture
def normal_entry() -> dict[str, str]:
    """Return a sample normal entry."""
    return copy.deepcopy(normal_entry_dictionary)


@pytest.fixture
def one_line_entry() -> dict[str, str]:
    """Return a sample one line entry."""
    return copy.deepcopy(one_line_entry_dictionary)


@pytest.fixture
def bullet_entry() -> dict[str, str]:
    """Return a sample bullet entry."""
    return copy.deepcopy(bullet_entry_dictionary)


@pytest.fixture
def text_entry() -> str:
    """Return a sample text entry."""
    return (
        "This is a *TextEntry*. It is only a text and can be useful for sections like"
        " **Summary**. To showcase the TextEntry completely, this sentence is added,"
        " but it doesn't contain any information."
    )


@pytest.fixture
def rendercv_data_model() -> dm.RenderCVDataModel:
    """Return a sample RenderCV data model."""
    return dm.get_a_sample_data_model()


@pytest.fixture
def rendercv_empty_curriculum_vitae_data_model() -> dm.CurriculumVitae:
    """Return an empty CurriculumVitae data model."""
    return dm.CurriculumVitae(sections={"test": ["test"]})


def return_a_value_for_a_field_type(
    field: str,
    field_type: typing.Any,
) -> str:
    """Return a value for a given field and field type.

    Example:
        ```python
        return_a_value_for_a_field_type("institution", str)
        ```
        will return:
        `#!python "Boğaziçi University"`

    Args:
        field_type (typing.Any): _description_

    Returns:
        str: _description_
    """
    field_dictionary = {
        "institution": "Boğaziçi University",
        "location": "Istanbul, Turkey",
        "degree": "BS",
        "area": "Mechanical Engineering",
        "start_date": "2015-09",
        "end_date": "2020-06",
        "date": "2021-09",
        "highlights": [
            "Did *this* and this is a **bold** [link](https://example.com).",
            "Did that.",
        ],
        "company": "Some **Company**",
        "position": "Software Engineer",
        "name": "My Project",
        "label": "Pro**gram**ming",
        "details": "Python, C++, JavaScript, MATLAB",
        "authors": ["J. Doe", "**H. Tom**", "S. Doe", "A. Andsurname"],
        "title": (
            "Magneto-Thermal Thin Shell Approximation for 3D Finite Element Analysis of"
            " No-Insulation Coils"
        ),
        "journal": "IEEE Transactions on Applied Superconductivity",
        "doi": "10.1109/TASC.2023.3340648",
    }

    field_type_dictionary = {
        pydantic.HttpUrl: "https://example.com",
        pydantic_phone_numbers.PhoneNumber: "+905419999999",
        str: "A string",
        list[str]: ["A string", "Another string"],
        int: 1,
        float: 1.0,
        bool: True,
    }

    if type(None) in typing.get_args(field_type):
        return return_a_value_for_a_field_type(field, field_type.__args__[0])
    elif typing.get_origin(field_type) == typing.Literal:
        return field_type.__args__[0]
    elif typing.get_origin(field_type) == typing.Union:
        return return_a_value_for_a_field_type(field, field_type.__args__[0])
    elif field in field_dictionary:
        return field_dictionary[field]
    elif field_type in field_type_dictionary:
        return field_type_dictionary[field_type]

    return "A string"


def create_combinations_of_a_model(
    model: pydantic.BaseModel,
) -> list[pydantic.BaseModel]:
    """Look at the required fields and optional fields of a model and create all
    possible combinations of them.

    Args:
        model (pydantic.BaseModel): The data model class to create combinations of.

    Returns:
        list[pydantic.BaseModel]: All possible instances of the model.
    """
    fields = typing.get_type_hints(model)

    required_fields = dict()
    optional_fields = dict()

    for field, field_type in fields.items():
        value = return_a_value_for_a_field_type(field, field_type)
        if type(None) in typing.get_args(field_type):  # check if a field is optional
            optional_fields[field] = value
        else:
            required_fields[field] = value

    model_with_only_required_fields = model(**required_fields)

    # create all possible combinations of optional fields
    all_combinations = [model_with_only_required_fields]
    for i in range(1, len(optional_fields) + 1):
        for combination in itertools.combinations(optional_fields, i):
            kwargs = {k: optional_fields[k] for k in combination}
            model = copy.deepcopy(model_with_only_required_fields)
            model.__dict__.update(kwargs)
            all_combinations.append(model)

    return all_combinations


@pytest.fixture
def rendercv_filled_curriculum_vitae_data_model(
    text_entry, bullet_entry
) -> dm.CurriculumVitae:
    """Return a filled CurriculumVitae data model, where each section has all possible
    combinations of entry types.
    """
    return dm.CurriculumVitae(
        name="John Doe",
        label="Mechanical Engineer",
        location="Istanbul, Turkey",
        email="johndoe@example.com",
        phone="+905419999999",  # type: ignore
        website="https://example.com",  # type: ignore
        social_networks=[
            dm.SocialNetwork(network="LinkedIn", username="johndoe"),
            dm.SocialNetwork(network="GitHub", username="johndoe"),
            dm.SocialNetwork(network="Instagram", username="johndoe"),
            dm.SocialNetwork(network="Orcid", username="0000-0000-0000-0000"),
            dm.SocialNetwork(network="Mastodon", username="@johndoe@example"),
            dm.SocialNetwork(network="Twitter", username="johndoe"),
        ],
        sections={
            "Text Entries": [text_entry, text_entry, text_entry],
            "Bullet Entries": [bullet_entry, bullet_entry],
            "Publication Entries": create_combinations_of_a_model(dm.PublicationEntry),
            "Experience Entries": create_combinations_of_a_model(dm.ExperienceEntry),
            "Education Entries": create_combinations_of_a_model(dm.EducationEntry),
            "Normal Entries": create_combinations_of_a_model(dm.NormalEntry),
            "One Line Entries": create_combinations_of_a_model(dm.OneLineEntry),
        },
    )


@pytest.fixture
def jinja2_environment() -> jinja2.Environment:
    """Return a Jinja2 environment."""
    return r.setup_jinja2_environment()


@pytest.fixture
def tests_directory_path() -> pathlib.Path:
    """Return the path to the tests directory."""
    return pathlib.Path(__file__).parent


@pytest.fixture
def root_directory_path(tests_directory_path) -> pathlib.Path:
    """Return the path to the repository's root directory."""
    return tests_directory_path.parent


@pytest.fixture
def testdata_directory_path(tests_directory_path) -> pathlib.Path:
    """Return the path to the testdata directory."""
    return tests_directory_path / "testdata"


@pytest.fixture
def specific_testdata_directory_path(testdata_directory_path, request) -> pathlib.Path:
    """Return the path to a specific testdata directory.

    For example, if the test function is named `test_rendercv`, this will return the
    path to the `testdata/test_rendercv` directory.
    """
    return testdata_directory_path / request.node.originalname


def are_these_two_directories_the_same(
    directory1: pathlib.Path, directory2: pathlib.Path
) -> None:
    """Check if two directories are the same.

    Args:
        directory1 (pathlib.Path): The first directory to compare.
        directory2 (pathlib.Path): The second directory to compare.

    Raises:
        AssertionError: If the two directories are not the same.
    """
    for file1 in directory1.iterdir():
        if file1.name == "__pycache__":
            continue

        file2 = directory2 / file1.name
        if file1.is_dir():
            if not file2.is_dir():
                return False
            are_these_two_directories_the_same(file1, file2)
        else:
            if are_these_two_files_the_same(file1, file2) is False:
                return False

    return True


def are_these_two_files_the_same(file1: pathlib.Path, file2: pathlib.Path) -> None:
    """Check if two files are the same.

    Args:
        file1 (pathlib.Path): The first file to compare.
        file2 (pathlib.Path): The second file to compare.

    Raises:
        AssertionError: If the two files are not the same.
    """
    extension1 = file1.suffix
    extension2 = file2.suffix

    if extension1 != extension2:
        return False

    if extension1 == ".pdf":
        pages1 = pypdf.PdfReader(file1).pages
        pages2 = pypdf.PdfReader(file2).pages
        if len(pages1) != len(pages2):
            return False

        for i in range(len(pages1)):
            if pages1[i].extract_text() != pages2[i].extract_text():
                return False

        return True
    else:
        return filecmp.cmp(file1, file2)


@pytest.fixture
def run_a_function_and_check_if_output_is_the_same_as_reference(
    tmp_path: pathlib.Path,
    specific_testdata_directory_path: pathlib.Path,
) -> typing.Callable:
    """Run a function and check if the output is the same as the reference."""

    def function(
        function: typing.Callable,
        reference_file_or_directory_name: str,
        output_file_name: Optional[str] = None,
        generate_reference_files_function: Optional[typing.Callable] = None,
        **kwargs,
    ):
        output_is_a_single_file = output_file_name is not None
        if output_is_a_single_file:
            output_file_path = tmp_path / output_file_name

        reference_directory_path: pathlib.Path = specific_testdata_directory_path
        reference_file_or_directory_path = (
            reference_directory_path / reference_file_or_directory_name
        )

        # Update the testdata if update_testdata is True
        if update_testdata:
            # create the reference directory if it does not exist
            reference_directory_path.mkdir(parents=True, exist_ok=True)

            # remove the reference file or directory if it exists
            if reference_file_or_directory_path.is_dir():
                shutil.rmtree(reference_file_or_directory_path)
            elif reference_file_or_directory_path.exists():
                reference_file_or_directory_path.unlink()

            if generate_reference_files_function:
                generate_reference_files_function(
                    reference_file_or_directory_path, **kwargs
                )
            else:
                # copy the output file or directory to the reference directory
                function(tmp_path, reference_file_or_directory_path, **kwargs)
                if output_is_a_single_file:
                    shutil.move(output_file_path, reference_file_or_directory_path)
                else:
                    shutil.move(tmp_path, reference_file_or_directory_path)
                    os.mkdir(tmp_path)

        function(tmp_path, reference_file_or_directory_path, **kwargs)

        if output_is_a_single_file:
            return are_these_two_files_the_same(
                output_file_path, reference_file_or_directory_path
            )
        else:
            return are_these_two_directories_the_same(
                tmp_path, reference_file_or_directory_path
            )

    return function


@pytest.fixture
def input_file_path(testdata_directory_path) -> pathlib.Path:
    """Return the path to the input file."""
    return testdata_directory_path / "John_Doe_CV.yaml"
