from __future__ import annotations

import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, Mapping, Sequence

from pdftomd.models import Asset


@dataclass(frozen=True)
class QualityIssue:
    check: str
    message: str
    line: int | None = None
    column: int | None = None


@dataclass(frozen=True)
class QualityResult:
    markdown: str
    issues: tuple[QualityIssue, ...] = field(default_factory=tuple)

    @property
    def ok(self) -> bool:
        return not self.issues


_FENCE_RE = re.compile(r"(?ms)^```.*?^```")
_INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
_LATEX_ENV_RE = re.compile(r"\\(?P<kind>begin|end)\{(?P<name>[^{}]+)\}")
_IMAGE_RE = re.compile(r"!\[[^\]]*]\((?P<target>[^)\n]+)\)")
_ANCHOR_RE = re.compile(r"<a\s+(?:[^>]*?\s)?(?:id|name)=[\"'](?P<id>[^\"']+)[\"'][^>]*>", re.IGNORECASE)
_MARKDOWN_LINK_RE = re.compile(r"\[(?P<label>[^\]]+)]\((?P<target>#[^)]+)\)")
_REFERENCE_LABEL_RE = re.compile(r"^(?:fig(?:ure)?\.?|table|eq(?:uation)?\.?)\s*\d+", re.IGNORECASE)
_ANCHOR_ID_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")


def validate_markdown_quality(
    markdown: str,
    *,
    base_dir: Path | None = None,
    assets: Sequence[Asset] | Mapping[str, Asset] | None = None,
) -> QualityResult:
    issues: list[QualityIssue] = []
    issues.extend(validate_math_delimiters(markdown))
    issues.extend(validate_latex_environments(markdown))
    issues.extend(validate_image_links(markdown, base_dir=base_dir, assets=assets))
    issues.extend(validate_tables(markdown))
    issues.extend(validate_chunk_frontmatter(markdown))
    issues.extend(validate_caption_reference_anchors(markdown))
    return QualityResult(markdown=markdown, issues=tuple(issues))


def validate_math_delimiters(markdown: str) -> list[QualityIssue]:
    text = _mask_code(markdown)
    issues: list[QualityIssue] = []
    block_open: tuple[int, int] | None = None

    for line_number, line in enumerate(text.splitlines(), start=1):
        index = 0
        inline_open_column: int | None = None
        while index < len(line):
            if line[index] != "$" or _is_escaped(line, index):
                index += 1
                continue

            if line.startswith("$$", index):
                if inline_open_column is not None:
                    issues.append(
                        QualityIssue(
                            "math_delimiters",
                            "Unclosed inline math delimiter '$' before block math delimiter.",
                            line_number,
                            inline_open_column,
                        )
                    )
                    inline_open_column = None
                if block_open is None:
                    block_open = (line_number, index + 1)
                else:
                    block_open = None
                index += 2
                continue

            if block_open is None:
                if inline_open_column is None:
                    inline_open_column = index + 1
                else:
                    inline_open_column = None
            index += 1

        if inline_open_column is not None:
            issues.append(
                QualityIssue(
                    "math_delimiters",
                    "Unclosed inline math delimiter '$' on the same line.",
                    line_number,
                    inline_open_column,
                )
            )

    if block_open is not None:
        issues.append(
            QualityIssue(
                "math_delimiters",
                "Unclosed block math delimiter '$$'.",
                block_open[0],
                block_open[1],
            )
        )
    return issues


def validate_latex_environments(markdown: str) -> list[QualityIssue]:
    text = _mask_code(markdown)
    stack: list[tuple[str, int, int]] = []
    issues: list[QualityIssue] = []

    for match in _LATEX_ENV_RE.finditer(text):
        line, column = _line_column(text, match.start())
        kind = match.group("kind")
        name = match.group("name")
        if kind == "begin":
            stack.append((name, line, column))
            continue
        if not stack:
            issues.append(
                QualityIssue(
                    "latex_environments",
                    f"LaTeX environment end has no matching begin: {name}",
                    line,
                    column,
                )
            )
            continue
        expected, begin_line, begin_column = stack.pop()
        if expected != name:
            issues.append(
                QualityIssue(
                    "latex_environments",
                    f"LaTeX environment mismatch: began {expected} at line {begin_line}, ended {name}.",
                    line,
                    column,
                )
            )

    for name, line, column in stack:
        issues.append(
            QualityIssue(
                "latex_environments",
                f"LaTeX environment begin has no matching end: {name}",
                line,
                column,
            )
        )
    return issues


def validate_image_links(
    markdown: str,
    *,
    base_dir: Path | None = None,
    assets: Sequence[Asset] | Mapping[str, Asset] | None = None,
) -> list[QualityIssue]:
    modeled_paths = _asset_paths(assets)
    issues: list[QualityIssue] = []
    for match in _IMAGE_RE.finditer(_mask_code(markdown)):
        target = _normalize_link_target(match.group("target"))
        if _is_external_or_anchor(target):
            continue
        exists_on_disk = base_dir is not None and (base_dir / target).exists()
        exists_in_model = target in modeled_paths
        if not exists_on_disk and not exists_in_model:
            line, column = _line_column(markdown, match.start("target"))
            issues.append(
                QualityIssue(
                    "image_links",
                    f"Image link target does not exist on disk and is not present in modeled assets: {target}",
                    line,
                    column,
                )
            )
    return issues


def validate_tables(markdown: str, *, allow_html_table_fallback: bool = True) -> list[QualityIssue]:
    lines = markdown.splitlines()
    issues: list[QualityIssue] = []
    in_html_table = False
    index = 0

    while index < len(lines):
        line = lines[index]
        if re.search(r"<table\b", line, re.IGNORECASE):
            if allow_html_table_fallback:
                in_html_table = True
            else:
                issues.append(QualityIssue("tables", "HTML table fallback is not allowed here.", index + 1, 1))
            index += 1
            continue
        if in_html_table:
            if re.search(r"</table>", line, re.IGNORECASE):
                in_html_table = False
            index += 1
            continue
        if not _looks_like_table_row(line):
            index += 1
            continue
        if index + 1 >= len(lines) or not _is_separator_row(lines[index + 1]):
            index += 1
            continue

        expected = len(_table_cells(line))
        separator_cells = _table_cells(lines[index + 1])
        if expected < 2:
            issues.append(QualityIssue("tables", "Markdown table must contain at least two columns.", index + 1, 1))
        if len(separator_cells) != expected:
            issues.append(
                QualityIssue(
                    "tables",
                    f"Markdown table separator has {len(separator_cells)} cells; expected {expected}.",
                    index + 2,
                    1,
                )
            )

        index += 2
        while index < len(lines) and _looks_like_table_row(lines[index]):
            cell_count = len(_table_cells(lines[index]))
            if cell_count != expected:
                issues.append(
                    QualityIssue(
                        "tables",
                        f"Markdown table row has {cell_count} cells; expected {expected}.",
                        index + 1,
                        1,
                    )
                )
            index += 1

    if in_html_table:
        issues.append(QualityIssue("tables", "HTML table fallback is missing closing </table>.", len(lines), 1))
    return issues


def validate_chunk_frontmatter(
    markdown: str,
    *,
    required_fields: Iterable[str] = ("title", "document_slug", "chunk_index", "page_range"),
) -> list[QualityIssue]:
    frontmatter, first_body_line = _parse_frontmatter(markdown)
    if frontmatter is None:
        return [
            QualityIssue(
                "frontmatter",
                "Chunk frontmatter must start with an opening '---' line and a closing '---' line.",
                1,
                1,
            )
        ]

    issues: list[QualityIssue] = []
    for field_name in required_fields:
        if field_name not in frontmatter or frontmatter[field_name] == "":
            issues.append(
                QualityIssue(
                    "frontmatter",
                    f"Chunk frontmatter is missing required field: {field_name}",
                    first_body_line,
                    1,
                )
            )
    return issues


def validate_caption_reference_anchors(markdown: str) -> list[QualityIssue]:
    issues: list[QualityIssue] = []
    anchor_ids: set[str] = set()

    for match in _ANCHOR_RE.finditer(markdown):
        anchor_id = match.group("id")
        line, column = _line_column(markdown, match.start("id"))
        if not _ANCHOR_ID_RE.match(anchor_id):
            issues.append(QualityIssue("anchors", f"Anchor id has an unsupported shape: {anchor_id}", line, column))
        anchor_ids.add(anchor_id)

    for match in _MARKDOWN_LINK_RE.finditer(markdown):
        label = match.group("label").strip()
        if not _REFERENCE_LABEL_RE.match(label):
            continue
        target = match.group("target")[1:]
        line, column = _line_column(markdown, match.start("target"))
        if not _ANCHOR_ID_RE.match(target):
            issues.append(QualityIssue("anchors", f"Reference link anchor has an unsupported shape: #{target}", line, column))
        elif target not in anchor_ids:
            issues.append(QualityIssue("anchors", f"Reference link points to a missing anchor: #{target}", line, column))
    return issues


def _mask_code(markdown: str) -> str:
    def replace_with_spaces(match: re.Match[str]) -> str:
        return "".join("\n" if char == "\n" else " " for char in match.group(0))

    masked = _FENCE_RE.sub(replace_with_spaces, markdown)
    return _INLINE_CODE_RE.sub(replace_with_spaces, masked)


def _is_escaped(line: str, index: int) -> bool:
    slash_count = 0
    cursor = index - 1
    while cursor >= 0 and line[cursor] == "\\":
        slash_count += 1
        cursor -= 1
    return slash_count % 2 == 1


def _line_column(text: str, offset: int) -> tuple[int, int]:
    line = text.count("\n", 0, offset) + 1
    previous_newline = text.rfind("\n", 0, offset)
    column = offset + 1 if previous_newline == -1 else offset - previous_newline
    return line, column


def _asset_paths(assets: Sequence[Asset] | Mapping[str, Asset] | None) -> set[str]:
    if assets is None:
        return set()
    values = assets.values() if isinstance(assets, Mapping) else assets
    return {asset.relative_path.replace("\\", "/") for asset in values}


def _normalize_link_target(target: str) -> str:
    target = target.strip()
    if target.startswith("<") and ">" in target:
        target = target[1 : target.index(">")]
    elif " " in target:
        target = target.split(" ", 1)[0]
    return target.replace("\\", "/")


def _is_external_or_anchor(target: str) -> bool:
    lowered = target.lower()
    return target.startswith("#") or lowered.startswith(("http://", "https://", "mailto:", "data:"))


def _looks_like_table_row(line: str) -> bool:
    return "|" in line and bool(line.strip().strip("|").strip())


def _table_cells(line: str) -> list[str]:
    return [cell.strip() for cell in line.strip().strip("|").split("|")]


def _is_separator_row(line: str) -> bool:
    cells = _table_cells(line)
    return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell.strip()) for cell in cells)


def _parse_frontmatter(markdown: str) -> tuple[dict[str, str] | None, int]:
    lines = markdown.splitlines()
    if not lines or lines[0].strip() != "---":
        return None, 1
    for index, line in enumerate(lines[1:], start=1):
        if line.strip() == "---":
            fields: dict[str, str] = {}
            for raw_field in lines[1:index]:
                if not raw_field.strip() or raw_field.lstrip().startswith("#"):
                    continue
                if ":" not in raw_field:
                    continue
                key, value = raw_field.split(":", 1)
                fields[key.strip()] = value.strip().strip("'\"")
            return fields, index + 2
    return None, 1
