import pathlib
from typing import Any, cast

import pydantic
import pydantic_core
from ruamel.yaml.comments import CommentedMap

from rendercv.exception import (
    OVERLAY_SOURCE_TO_YAML_SOURCE,
    OverlaySourceKey,
    RenderCVInternalError,
    RenderCVValidationError,
    YamlSource,
)

from .models.custom_error_types import CustomPydanticErrorTypes
from .yaml_reader import read_yaml

error_dictionary = cast(
    dict[str, str],
    read_yaml(pathlib.Path(__file__).parent / "error_dictionary.yaml"),
)
unwanted_texts = ("value is not a valid email address: ", "Value error, ")
unwanted_locations = (
    "tagged-union",
    "list",
    "literal",
    "int",
    "str",
    "constrained-str",
    "function-",
)


def parse_plain_pydantic_error(
    plain_error: pydantic_core.ErrorDetails,
    input_dictionary: CommentedMap | dict[str, Any],
    overlay_sources: dict[str, CommentedMap] | None = None,
) -> RenderCVValidationError:
    """Transform raw Pydantic error into user-friendly validation error with YAML coordinates.

    Args:
        plain_error: Raw Pydantic validation error.
        input_dictionary: YAML dict with line/column metadata.
        overlay_sources: Per-section CommentedMaps from overlays (for correct coordinates).

    Returns:
        Structured error with location tuple, friendly message, and YAML coordinates.
    """
    for unwanted_text in unwanted_texts:
        plain_error["msg"] = plain_error["msg"].replace(unwanted_text, "")

    if plain_error["loc"][0] in ["design", "locale"]:
        # Skip the second key because it's the discriminator value.
        plain_error["loc"] = plain_error["loc"][:1] + plain_error["loc"][2:]

    if "ctx" in plain_error:
        if "input" in plain_error["ctx"]:
            plain_error["input"] = plain_error["ctx"]["input"]

        if "loc" in plain_error["ctx"]:
            plain_error["loc"] = plain_error["ctx"]["loc"]

    location = tuple(
        str(location_element)
        for location_element in plain_error["loc"]
        if not any(item in str(location_element) for item in unwanted_locations)
    )
    # Special case for end_date because Pydantic returns multiple end_date errors
    # since it has multiple valid formats:
    if location and "end_date" in location[-1]:
        plain_error["msg"] = (
            "This is not a valid `end_date`! Please use either YYYY-MM-DD, YYYY-MM,"
            ' or YYYY format or "present"!'
        )

    # Special case for current_date: the field is typed as datetime.date |
    # Literal["today"]. Pydantic appends "date" to the loc for the datetime.date
    # union branch (e.g. ("settings", "current_date", "date")). Strip that suffix
    # first so the field name lands at location[-1], matching the end_date pattern.
    if len(location) >= 2 and location[-1] == "date" and location[-2] == "current_date":
        location = location[:-1]
    if location and "current_date" in location[-1]:
        plain_error["msg"] = (
            "This is not a valid `current_date`! Please use YYYY-MM-DD format or"
            ' "today".'
        )

    for old_error_message, new_error_message in error_dictionary.items():
        if old_error_message in plain_error["msg"]:
            plain_error["msg"] = new_error_message
            break

    if not plain_error["msg"].endswith("."):
        plain_error["msg"] += "."

    # Determine which YAML source this error came from and use the correct
    # CommentedMap for coordinate lookup
    yaml_source: YamlSource = "main_yaml_file"
    coord_dict: CommentedMap | dict[str, Any] = input_dictionary
    if overlay_sources and location and location[0] in overlay_sources:
        source_key = cast(OverlaySourceKey, location[0])
        yaml_source = OVERLAY_SOURCE_TO_YAML_SOURCE[source_key]
        coord_dict = overlay_sources[source_key]

    location_for_coords = (
        location if plain_error["type"] != "missing" else location[:-1]
    )

    return RenderCVValidationError(
        schema_location=location,
        yaml_location=(
            get_coordinates_of_a_key_in_a_yaml_object(
                coord_dict,
                location_for_coords,
            )
            if isinstance(coord_dict, CommentedMap)
            else None
        ),
        yaml_source=yaml_source,
        message=plain_error["msg"],
        input=(
            str(plain_error["input"])
            if not isinstance(plain_error["input"], dict | list)
            else "..."
        ),
    )


def parse_validation_errors(
    exception: pydantic.ValidationError,
    input_dictionary: CommentedMap | dict[str, Any],
    overlay_sources: dict[str, CommentedMap] | None = None,
) -> list[RenderCVValidationError]:
    """Extract all validation errors from Pydantic exception with deduplication.

    Args:
        exception: Pydantic validation exception.
        input_dictionary: YAML dict with location metadata.
        overlay_sources: Per-section CommentedMaps from overlays (for correct coordinates).

    Returns:
        Deduplicated list of user-friendly validation errors.
    """
    all_plain_errors = exception.errors()
    all_final_errors: list[RenderCVValidationError] = []

    for plain_error in all_plain_errors:
        all_final_errors.append(
            parse_plain_pydantic_error(plain_error, input_dictionary, overlay_sources)
        )

        if plain_error["type"] == CustomPydanticErrorTypes.entry_validation.value:
            if "ctx" not in plain_error or "caused_by" not in plain_error["ctx"]:
                raise RenderCVInternalError(
                    "entry_validation error missing ctx or caused_by"
                )
            for plain_cause_error in plain_error["ctx"]["caused_by"]:
                loc = plain_cause_error["loc"][1:]  # Omit `entries` location
                plain_cause_error["loc"] = plain_error["loc"] + loc
                all_final_errors.append(
                    parse_plain_pydantic_error(
                        plain_cause_error, input_dictionary, overlay_sources
                    )
                )

    # Remove duplicates from all_final_errors:
    error_locations = set()
    errors_without_duplicates = []
    for error in all_final_errors:
        schema_location = error.schema_location
        if schema_location not in error_locations:
            error_locations.add(schema_location)
            errors_without_duplicates.append(error)

    return errors_without_duplicates


def get_inner_yaml_object_from_its_key(
    yaml_object: CommentedMap,
    location_key: str,
) -> tuple[CommentedMap, tuple[tuple[int, int], tuple[int, int]]]:
    """Navigate one level into YAML structure and extract coordinates.

    Why:
        Error locations are dotted paths like `cv.sections.education.0.degree`.
        Each traversal step must extract both the nested object and its exact
        source coordinates for error highlighting.

    Args:
        yaml_object: Current YAML object being traversed.
        location_key: Single key or list index as string.

    Returns:
        Tuple of nested object and ((start_line, start_col), (end_line, end_col)).
    """
    # If the part is numeric, interpret it as a list index:
    try:
        index = int(location_key)
        try:
            inner_yaml_object = yaml_object[index]
            # Get the coordinates from the list's lc.data (which is a list of tuples).
            start_line, start_col = yaml_object.lc.data[index]
            end_line, end_col = start_line, start_col
            coordinates = ((start_line + 1, start_col - 1), (end_line + 1, end_col))
        except IndexError as e:
            message = f"Index {index} is out of range in the YAML file."
            raise RenderCVInternalError(message) from e
    except ValueError as e:
        # Otherwise, the part is a key in a mapping.
        if location_key not in yaml_object:
            message = f"Key '{location_key}' not found in the YAML file."
            raise RenderCVInternalError(message) from e

        inner_yaml_object = yaml_object[location_key]
        start_line, start_col, end_line, end_col = yaml_object.lc.data[location_key]
        coordinates = ((start_line + 1, start_col + 1), (end_line + 1, end_col))

    return inner_yaml_object, coordinates


def get_coordinates_of_a_key_in_a_yaml_object(
    yaml_object: CommentedMap, location: tuple[str, ...]
) -> tuple[tuple[int, int], tuple[int, int]]:
    """Resolve dotted location path to exact YAML source coordinates.

    Why:
        Error tables must show users exactly which line/column contains
        the invalid value. Recursive traversal through CommentedMap's
        lc.data preserves coordinates at every nesting level.

    Example:
        ```py
        data = read_yaml(pathlib.Path("cv.yaml"))
        coords = get_coordinates_of_a_key_in_a_yaml_object(
            data, ("cv", "sections", "education", "0", "degree")
        )
        # coords = ((12, 4), (12, 10)) for line 12, columns 4-10
        ```

    Args:
        yaml_object: Root YAML object with location metadata.
        location: Path segments from root to target key.

    Returns:
        ((start_line, start_col), (end_line, end_col)) in 1-indexed coordinates.
    """

    current_yaml_object = yaml_object
    coordinates = ((0, 0), (0, 0))
    # start from the first key and move forward:
    for location_key in location:
        current_yaml_object, coordinates = get_inner_yaml_object_from_its_key(
            current_yaml_object, location_key
        )

    return coordinates
