"""Document assembly for Elsevier XML-derived article Markdown."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any
from collections.abc import Mapping
import re
import xml.etree.ElementTree as ET

from ..models import Reference, SemanticLosses
from ..publisher_identity import extract_doi, normalize_doi
from ..utils import _extract_year
from ._article_markdown_common import (
    child_text,
    collect_conversion_notes,
    first_child,
    first_descendant,
    make_markdown_path,
    normalize_lines,
    normalize_text,
    path_relative_to,
    render_figure_block,
    render_table_block,
    xml_local_name,
)
from ._article_markdown_elsevier import (
    elsevier_figure_registry,
    elsevier_supplement_entries,
    elsevier_table_registry,
    render_elsevier_blocks,
)
from ..utils import dedupe_authors


@dataclass
class ArticleStructure:
    title: str
    doi: str
    journal_title: str
    published: str
    landing_page: str
    authors: list[str]
    xml_path: Path
    abstract_lines: list[str]
    body_lines: list[str]
    figure_entries: list[dict[str, Any]]
    table_entries: list[dict[str, Any]]
    supplement_entries: list[dict[str, str]]
    conversion_notes: list[str]
    references: list[Reference]
    semantic_losses: SemanticLosses
    used_figure_keys: set[str]
    used_table_keys: set[str]


def _iter_elements_by_local_name(root: ET.Element, local_name: str) -> list[ET.Element]:
    return [
        element
        for element in root.iter()
        if isinstance(element.tag, str) and xml_local_name(element.tag) == local_name
    ]


def _contains_element(root: ET.Element | None, target: ET.Element) -> bool:
    return root is not None and any(element is target for element in root.iter())


def _extract_author_name(author_node: ET.Element) -> str:
    given_name = normalize_text(child_text(author_node, "given-name"))
    surname = normalize_text(child_text(author_node, "surname"))
    if given_name or surname:
        return normalize_text(" ".join(item for item in (given_name, surname) if item))
    indexed_name = normalize_text(child_text(author_node, "indexed-name"))
    if indexed_name:
        return indexed_name
    return normalize_text(child_text(author_node, "collaboration"))


def extract_article_authors(root: ET.Element) -> list[str]:
    authors: list[str] = []
    for author_group in _iter_elements_by_local_name(root, "author-group"):
        for child in list(author_group):
            if not isinstance(child.tag, str):
                continue
            local_name = xml_local_name(child.tag)
            if local_name == "author":
                author_name = _extract_author_name(child)
                if author_name:
                    authors.append(author_name)
            elif local_name == "collaboration":
                collaboration_name = normalize_text("".join(child.itertext()))
                if collaboration_name:
                    authors.append(collaboration_name)
    return dedupe_authors(authors)


def _child_text(element: ET.Element | None, local_name: str) -> str:
    return normalize_text(child_text(element, local_name))


def _first_descendant_text(element: ET.Element | None, local_name: str) -> str:
    node = first_descendant(element, local_name)
    return normalize_text("".join(node.itertext())) if node is not None else ""


def _extract_reference_authors(contribution: ET.Element | None) -> list[str]:
    authors_node = first_child(contribution, "authors")
    if authors_node is None:
        return []

    authors: list[str] = []
    for child in list(authors_node):
        if not isinstance(child.tag, str):
            continue
        local_name = xml_local_name(child.tag)
        if local_name == "author":
            author_name = _extract_author_name(child)
            if author_name:
                authors.append(author_name)
            continue
        if local_name == "collaboration":
            collaboration_name = normalize_text("".join(child.itertext()))
            if collaboration_name:
                authors.append(collaboration_name)
    return dedupe_authors(authors)


def _extract_reference_title(contribution: ET.Element | None) -> str:
    title_node = first_child(contribution, "title")
    return _child_text(title_node, "maintitle") or _first_descendant_text(contribution, "maintitle")


def _extract_reference_source(host: ET.Element | None) -> str:
    series_node = first_descendant(host, "series")
    title_node = first_child(series_node, "title")
    return _child_text(title_node, "maintitle") or _first_descendant_text(host, "maintitle")


def _format_reference_body(
    *,
    authors: list[str],
    title: str,
    source: str,
    volume: str,
    issue: str,
    year: str,
    first_page: str,
    last_page: str,
    doi: str,
    source_text: str,
) -> str:
    parts: list[str] = []
    if authors:
        parts.append(", ".join(authors))
    if year:
        parts.append(year)
    if title:
        parts.append(title)

    publication = source
    if volume:
        publication = f"{publication}, {volume}" if publication else volume
    if issue:
        publication = f"{publication}({issue})" if publication else f"({issue})"
    pages = ""
    if first_page and last_page:
        pages = f"{first_page}-{last_page}"
    elif first_page:
        pages = first_page
    if pages:
        publication = f"{publication}: {pages}" if publication else pages
    if publication:
        parts.append(publication)
    if doi:
        parts.append(doi)

    body = ". ".join(part.rstrip(".") for part in parts if part).strip()
    return body or source_text


def _raw_reference_text(bib_reference: ET.Element, *, label: str) -> str:
    text = normalize_text(" ".join(bib_reference.itertext()))
    if not text:
        return ""
    if label:
        label_text = normalize_text(label).strip("[](). ")
        if label_text:
            text = normalize_text(re.sub(rf"^\[?\s*{re.escape(label_text)}\s*\]?\.?\s*", "", text))
    return text


def _reference_body_is_doi_only(body: str, doi: str) -> bool:
    normalized_body = normalize_text(body).lower().rstrip(".,;")
    normalized_doi = normalize_doi(doi).lower().rstrip(".,;")
    if not normalized_body or not normalized_doi:
        return False
    return normalized_body in {normalized_doi, f"https://doi.org/{normalized_doi}", f"doi: {normalized_doi}"}


def _reference_label_is_numeric_counter(label: str, index: int) -> bool:
    normalized = normalize_text(label).strip("[](). ")
    if not normalized:
        return False
    return normalized == str(index) or bool(re.fullmatch(r"\d+[A-Za-z]?", normalized))


def extract_elsevier_references(root: ET.Element) -> list[Reference]:
    references: list[Reference] = []
    for index, bib_reference in enumerate(_iter_elements_by_local_name(root, "bib-reference"), start=1):
        label = _child_text(bib_reference, "label")
        sb_reference = first_child(bib_reference, "reference")
        if sb_reference is None:
            sb_reference = first_descendant(bib_reference, "reference")
        contribution = first_child(sb_reference, "contribution")
        host = first_child(sb_reference, "host")
        source_text = _child_text(bib_reference, "source-text")
        fallback_text = source_text or _raw_reference_text(bib_reference, label=label)
        doi = normalize_doi(_first_descendant_text(sb_reference, "doi")) or (extract_doi(source_text) or "")
        title = _extract_reference_title(contribution)
        year = _first_descendant_text(host, "date")
        body = _format_reference_body(
            authors=_extract_reference_authors(contribution),
            title=title,
            source=_extract_reference_source(host),
            volume=_first_descendant_text(host, "volume-nr"),
            issue=_first_descendant_text(host, "issue-nr"),
            year=year,
            first_page=_first_descendant_text(host, "first-page"),
            last_page=_first_descendant_text(host, "last-page"),
            doi=doi,
            source_text=fallback_text,
        )
        if fallback_text and _reference_body_is_doi_only(body, doi):
            body = fallback_text
        if not body:
            body = "[Reference text unavailable]"
        raw = f"{index}. {body}"
        if label and not _reference_label_is_numeric_counter(label, index) and not body.startswith(label):
            raw = f"{raw} [{label}]"
        references.append(
            Reference(
                raw=raw,
                doi=doi or None,
                title=title or None,
                year=year or None,
            )
        )
    return references


def _build_elsevier_article_structure(
    *,
    metadata: Mapping[str, Any],
    xml_body: bytes,
    xml_path: Path,
    assets: list[dict[str, Any]],
    xml_root: ET.Element | None = None,
) -> ArticleStructure | None:
    if xml_root is None:
        try:
            root = ET.fromstring(xml_body)
        except ET.ParseError:
            return None
    else:
        root = xml_root

    title = normalize_text(str(metadata.get("title") or "")) or "Untitled Article"
    doi = normalize_text(str(metadata.get("doi") or ""))
    journal_title = normalize_text(str(metadata.get("journal_title") or ""))
    published = normalize_text(str(metadata.get("published") or ""))
    landing_page = normalize_text(str(metadata.get("landing_page_url") or ""))
    authors = extract_article_authors(root)
    references: list[Reference] = []

    used_figure_keys: set[str] = set()
    used_table_keys: set[str] = set()
    formula_renders = []
    abstract_node = first_descendant(root, "abstract")
    body_node = first_descendant(root, "body")
    abstract_lines = render_elsevier_blocks(
        abstract_node,
        heading_level=3,
        formula_renders=formula_renders,
    )
    if not abstract_lines:
        fallback_abstract = normalize_text(
            str(metadata.get("abstract") or child_text(first_descendant(root, "coredata"), "description"))
        )
        if fallback_abstract:
            abstract_lines = [fallback_abstract, ""]
    table_lookup, table_entries = elsevier_table_registry(root, assets, xml_path.with_suffix(".md"))
    figure_lookup, figure_entries = elsevier_figure_registry(root, assets, xml_path.with_suffix(".md"))
    body_lines = render_elsevier_blocks(
        body_node,
        heading_level=3,
        figure_lookup=figure_lookup,
        figure_entries=figure_entries,
        used_figure_keys=used_figure_keys,
        table_lookup=table_lookup,
        used_table_keys=used_table_keys,
        formula_renders=formula_renders,
    )
    head_availability_lines: list[str] = []
    for availability_node in _iter_elements_by_local_name(root, "data-availability"):
        if _contains_element(body_node, availability_node):
            continue
        availability_title = child_text(availability_node, "section-title") or child_text(availability_node, "title")
        availability_body_lines = render_elsevier_blocks(
            availability_node,
            heading_level=4,
            figure_lookup=figure_lookup,
            figure_entries=figure_entries,
            used_figure_keys=used_figure_keys,
            table_lookup=table_lookup,
            used_table_keys=used_table_keys,
            formula_renders=formula_renders,
        )
        normalized_availability_title = normalize_text(availability_title)
        if normalized_availability_title and availability_body_lines:
            head_availability_lines.extend([f"### {normalized_availability_title}", ""])
        head_availability_lines.extend(availability_body_lines)
    body_lines.extend(head_availability_lines)
    supplement_entries = elsevier_supplement_entries(root, assets, xml_path.with_suffix(".md"))
    references = extract_elsevier_references(root)

    semantic_losses = SemanticLosses(
        table_fallback_count=sum(1 for entry in table_entries if normalize_text(str(entry.get("kind") or "")) == "fallback"),
        table_layout_degraded_count=sum(1 for entry in table_entries if normalize_text(str(entry.get("lossy_message") or ""))),
        formula_fallback_count=sum(1 for result in formula_renders if getattr(result, "fallback_kind", None) == "fallback"),
        formula_missing_count=sum(1 for result in formula_renders if getattr(result, "fallback_kind", None) == "missing"),
    )
    conversion_notes = collect_conversion_notes(
        table_entries=table_entries,
        formula_notes=[str(result.note) for result in formula_renders if normalize_text(str(result.note or ""))],
    )
    return ArticleStructure(
        title=title,
        doi=doi,
        journal_title=journal_title,
        published=published,
        landing_page=landing_page,
        authors=authors,
        xml_path=xml_path,
        abstract_lines=abstract_lines,
        body_lines=body_lines,
        figure_entries=figure_entries,
        table_entries=table_entries,
        supplement_entries=supplement_entries,
        conversion_notes=conversion_notes,
        references=references,
        semantic_losses=semantic_losses,
        used_figure_keys=used_figure_keys,
        used_table_keys=used_table_keys,
    )


_ARTICLE_STRUCTURE_BUILDERS = {
    "elsevier": _build_elsevier_article_structure,
}


def build_article_structure(
    *,
    provider: str,
    metadata: Mapping[str, Any],
    xml_body: bytes,
    xml_path: Path,
    assets: list[dict[str, Any]],
    xml_root: ET.Element | None = None,
) -> ArticleStructure | None:
    builder = _ARTICLE_STRUCTURE_BUILDERS.get(provider)
    if builder is None:
        return None
    return builder(
        metadata=metadata,
        xml_body=xml_body,
        xml_path=xml_path,
        assets=assets,
        xml_root=xml_root,
    )


def build_markdown_document(
    *,
    provider: str,
    metadata: Mapping[str, Any],
    xml_body: bytes,
    xml_path: Path,
    assets: list[dict[str, Any]],
    xml_root: ET.Element | None = None,
) -> str | None:
    structure = build_article_structure(
        provider=provider,
        metadata=metadata,
        xml_body=xml_body,
        xml_path=xml_path,
        assets=assets,
        xml_root=xml_root,
    )
    if structure is None:
        return None

    lines = [f"# {structure.title}", ""]
    if structure.doi:
        lines.append(f"- DOI: `{structure.doi}`")
    lines.append(f"- Provider: `{provider}`")
    if structure.journal_title:
        lines.append(f"- Journal: {structure.journal_title}")
    if structure.published:
        lines.append(f"- Published: {structure.published}")
    lines.append(f"- XML: [{structure.xml_path.name}]({path_relative_to(structure.xml_path.parent, structure.xml_path)})")
    if structure.landing_page:
        lines.append(f"- Landing Page: {structure.landing_page}")
    lines.append("")

    if structure.abstract_lines:
        lines.extend(["## Abstract", ""])
        lines.extend(structure.abstract_lines)

    if structure.body_lines:
        if lines and normalize_text(lines[-1]):
            lines.append("")
        lines.extend(structure.body_lines)

    remaining_figure_entries = [
        entry
        for entry in structure.figure_entries
        if entry["key"] not in structure.used_figure_keys and entry.get("section") == "body"
    ]
    if remaining_figure_entries:
        lines.extend(["## Additional Figures", ""])
        for entry in remaining_figure_entries:
            lines.extend([f"### {entry['heading']}", ""])
            lines.extend(render_figure_block(entry))

    remaining_table_entries = [
        entry
        for entry in structure.table_entries
        if str(entry["key"]) not in structure.used_table_keys and entry.get("section") == "body"
    ]
    if remaining_table_entries:
        lines.extend(["## Additional Tables", ""])
        for entry in remaining_table_entries:
            lines.extend(render_table_block(entry))

    if structure.supplement_entries:
        lines.extend(["## Supplementary Materials", ""])
        for entry in structure.supplement_entries:
            bullet = f"- [{entry['heading']}]({entry['link']})"
            if entry["caption"]:
                bullet = f"{bullet}: {entry['caption']}"
            lines.append(bullet)
        lines.append("")

    if structure.conversion_notes:
        lines.extend(["## Conversion Notes", ""])
        lines.extend(structure.conversion_notes)
        lines.append("")

    return normalize_lines(lines)


def write_article_markdown(
    *,
    provider: str,
    metadata: Mapping[str, Any],
    xml_body: bytes,
    output_dir: Path | None,
    xml_path: str | None,
    assets: list[dict[str, Any]] | None = None,
    xml_root: ET.Element | None = None,
) -> str | None:
    if output_dir is None or not xml_path:
        return None

    xml_output_path = Path(xml_path)
    markdown_path = make_markdown_path(
        output_dir,
        str(metadata.get("doi") or ""),
        metadata.get("title"),
        authors=metadata.get("authors") or None,
        year=_extract_year(str(metadata.get("published") or "") or None),
    )
    document = build_markdown_document(
        provider=provider,
        metadata=metadata,
        xml_body=xml_body,
        xml_path=xml_output_path,
        assets=list(assets or []),
        xml_root=xml_root,
    )
    if document is None:
        return None
    markdown_path.write_text(document, encoding="utf-8")
    return str(markdown_path)
