"""OneNote XML → Markdown converter.

Pure functions (no I/O beyond the ``ImageSink`` callback). Unit-testable
against fixture XML.

OneNote's own XML schema (``xmlns:one=".../onenote/2013/onenote"``) is used
by the desktop app's IApplication.GetPageContent call. Structure:

- ``<one:Page>`` — root, has ``<one:Title>`` + one or more ``<one:Outline>``
- ``<one:Outline>`` — a positioned block on the page; walk its ``<one:OEChildren>``
- ``<one:OE>`` — outline element, one block-level unit (paragraph, list item,
  image, table, ink). May contain ``<one:T>`` (inline HTML), ``<one:List>``
  (list bullet/number), ``<one:Image>`` (base64 in ``<one:Data>``),
  ``<one:Table>``, ``<one:InkDrawing>``, ``<one:InsertedFile>``, and nested
  ``<one:OEChildren>`` for sub-items.
- ``<one:T>`` — CDATA-wrapped HTML fragment (bold/italic/links/spans).

Headings aren't first-class in OneNote; they're encoded as font-size spans.
v1 doesn't try to detect them — we preserve inline formatting, lists,
images, and tables cleanly and leave heading detection as a future pass.
"""

from __future__ import annotations

import base64
import hashlib
import logging
import re
import xml.etree.ElementTree as ET
from collections.abc import Callable
from dataclasses import dataclass

log = logging.getLogger(__name__)

_ONE_NS = "http://schemas.microsoft.com/office/onenote/2013/onenote"
_NS = {"one": _ONE_NS}


@dataclass
class ConvertedPage:
    markdown: str
    images_saved: list[str]  # relative paths emitted in the markdown
    warnings: list[str]  # non-fatal issues surfaced to the caller


# Signature: (image_bytes, suggested_filename) → relative_path_in_markdown
# The caller owns where bytes go on disk; converter only produces the
# reference string that will appear in the markdown.
ImageSink = Callable[[bytes, str], str]


def convert_page(xml: str, image_sink: ImageSink) -> ConvertedPage:
    """Convert a OneNote page XML string to Markdown.

    ``image_sink`` receives (bytes, suggested_filename) for every inline image
    and returns the relative path that should appear in the markdown
    (e.g. ``".images/img-abc123.png"``). This lets the caller control
    filesystem layout without pulling Path handling into the converter.
    """
    warnings: list[str] = []
    saved_images: list[str] = []

    try:
        root = ET.fromstring(xml)
    except ET.ParseError as exc:
        return ConvertedPage(
            markdown=f"<!-- page parse failed: {exc} -->\n",
            images_saved=[],
            warnings=[f"XML parse error: {exc}"],
        )

    parts: list[str] = []

    # Walk every outline on the page in document order. OneNote pages can
    # have multiple outlines positioned at different x/y coordinates — we
    # flatten them into a single vertical flow.
    for outline in root.findall(".//one:Outline", _NS):
        children = outline.find("one:OEChildren", _NS)
        if children is None:
            continue
        block = _render_oe_children(
            children,
            depth=0,
            list_ctx=None,
            image_sink=image_sink,
            warnings=warnings,
            saved_images=saved_images,
        )
        if block.strip():
            parts.append(block)

    md = "\n\n".join(p.rstrip() for p in parts if p.strip()) + "\n"
    return ConvertedPage(markdown=md, images_saved=saved_images, warnings=warnings)


def extract_title(xml: str) -> str:
    """Pull the page title from the XML. Returns "" if not present."""
    try:
        root = ET.fromstring(xml)
    except ET.ParseError:
        return ""
    title_elem = root.find("one:Title", _NS)
    if title_elem is None:
        return ""
    # Title structure: <one:Title><one:OE><one:T><![CDATA[...]]></one:T></one:OE></one:Title>
    # The CDATA may contain HTML (e.g., <span>...</span>) — strip tags for
    # the filename-friendly title.
    text_parts: list[str] = []
    for t in title_elem.findall(".//one:T", _NS):
        if t.text:
            text_parts.append(_strip_html(t.text))
    return " ".join(text_parts).strip()


# ── OE rendering ─────────────────────────────────────────────────────


def _render_oe_children(
    children: ET.Element,
    *,
    depth: int,
    list_ctx: str | None,
    image_sink: ImageSink,
    warnings: list[str],
    saved_images: list[str],
) -> str:
    """Render one:OEChildren → markdown block.

    ``list_ctx`` is ``None`` for free-flowing blocks, ``"ul"`` / ``"ol"``
    when the children are list items of a particular kind.

    Numbered-list continuity: consecutive OEs carrying ``<one:Number>`` form
    a single enumerated list and the counter increments across them. A
    bulleted OE (or any non-list OE) interrupts the run and the next
    numbered OE restarts at 1. Mirrors how OneNote and Markdown render
    interleaved list kinds.
    """
    lines: list[str] = []
    ol_counter = 1
    prev_was_ol = False
    for oe in children.findall("one:OE", _NS):
        list_elem = oe.find("one:List", _NS)
        is_ol = list_elem is not None and list_elem.find("one:Number", _NS) is not None
        if is_ol:
            if not prev_was_ol:
                ol_counter = 1
            index = ol_counter
            ol_counter += 1
        else:
            index = 1
        prev_was_ol = is_ol

        rendered = _render_oe(
            oe,
            depth=depth,
            list_ctx=list_ctx,
            list_index=index,
            image_sink=image_sink,
            warnings=warnings,
            saved_images=saved_images,
        )
        if rendered:
            lines.append(rendered)
    return "\n".join(lines)


def _render_oe(
    oe: ET.Element,
    *,
    depth: int,
    list_ctx: str | None,
    list_index: int,
    image_sink: ImageSink,
    warnings: list[str],
    saved_images: list[str],
) -> str:
    """Render one outline element (one:OE) as a single markdown block.

    Block lines don't contain trailing newlines; the parent joins with \n.
    """
    # Ink — always skipped, just a marker comment.
    ink = oe.find("one:InkDrawing", _NS)
    if ink is not None:
        return "<!-- skipped: ink annotation -->"

    # Image — save bytes, emit markdown image reference.
    image = oe.find("one:Image", _NS)
    if image is not None:
        rel = _handle_image(image, image_sink, warnings)
        if rel:
            saved_images.append(rel)
            alt = image.get("alt") or ""
            return f"![{alt}]({rel})"
        return ""  # image failed to extract; warning already recorded

    # Inserted file — save bytes as attachment, link to it.
    attached = oe.find("one:InsertedFile", _NS)
    if attached is not None:
        rel = _handle_attachment(attached, image_sink, warnings)
        if rel:
            saved_images.append(rel)
            label = attached.get("preferredName") or "attachment"
            return f"[{label}]({rel})"
        return ""

    # Table.
    table = oe.find("one:Table", _NS)
    if table is not None:
        return _render_table(table, image_sink=image_sink, warnings=warnings, saved_images=saved_images)

    # Text + optional list marker.
    list_elem = oe.find("one:List", _NS)
    text_elem = oe.find("one:T", _NS)
    children_elem = oe.find("one:OEChildren", _NS)

    # Determine this OE's own text line (may be empty — e.g. a pure container).
    own_text = _render_text_node(text_elem) if text_elem is not None else ""

    # List prefixing.
    prefix = ""
    child_list_ctx = list_ctx
    if list_elem is not None:
        if list_elem.find("one:Number", _NS) is not None:
            prefix = f"{list_index}. "
            child_list_ctx = "ol"
        else:
            prefix = "- "
            child_list_ctx = "ul"
    indent = "  " * depth if (prefix or list_ctx) else ""

    rendered_self = ""
    if prefix or own_text:
        rendered_self = f"{indent}{prefix}{own_text}".rstrip()

    # Nested OEChildren — recurse. Children of a list item inherit the list
    # context ONE level deeper for indentation; their own list_ctx is decided
    # by their own <one:List> elements.
    rendered_children = ""
    if children_elem is not None:
        rendered_children = _render_oe_children(
            children_elem,
            depth=depth + 1,
            list_ctx=child_list_ctx if list_elem is not None else None,
            image_sink=image_sink,
            warnings=warnings,
            saved_images=saved_images,
        )

    if rendered_self and rendered_children:
        return f"{rendered_self}\n{rendered_children}"
    return rendered_self or rendered_children


def _render_text_node(text_elem: ET.Element) -> str:
    """Render a <one:T> CDATA HTML fragment as inline markdown."""
    raw = text_elem.text or ""
    # Try markdownify first; fall back to strip-tags if it isn't installed.
    try:
        from markdownify import markdownify  # type: ignore

        md = markdownify(raw, heading_style="ATX", bullets="-")
        # markdownify returns a block-flavored string; for an inline
        # fragment we want a single line. Collapse newlines.
        return re.sub(r"\s+", " ", md).strip()
    except ImportError:
        return _strip_html(raw)


# ── tables ───────────────────────────────────────────────────────────


def _render_table(
    table: ET.Element,
    *,
    image_sink: ImageSink,
    warnings: list[str],
    saved_images: list[str],
) -> str:
    rows_out: list[list[str]] = []
    for row in table.findall("one:Row", _NS):
        cells: list[str] = []
        for cell in row.findall("one:Cell", _NS):
            children = cell.find("one:OEChildren", _NS)
            if children is None:
                cells.append("")
                continue
            rendered = _render_oe_children(
                children,
                depth=0,
                list_ctx=None,
                image_sink=image_sink,
                warnings=warnings,
                saved_images=saved_images,
            )
            # Markdown table cells can't contain newlines — collapse.
            collapsed = re.sub(r"\s+", " ", rendered).strip()
            # Escape pipes so they don't break the row.
            collapsed = collapsed.replace("|", "\\|")
            cells.append(collapsed)
        if cells:
            rows_out.append(cells)

    if not rows_out:
        return ""

    width = max(len(r) for r in rows_out)
    # Pad short rows to the widest so we emit a rectangular table.
    padded = [r + [""] * (width - len(r)) for r in rows_out]

    header = padded[0]
    separator = ["---"] * width
    body = padded[1:] if len(padded) > 1 else []

    lines = [
        "| " + " | ".join(header) + " |",
        "| " + " | ".join(separator) + " |",
    ]
    for row in body:
        lines.append("| " + " | ".join(row) + " |")
    return "\n".join(lines)


# ── images / attachments ─────────────────────────────────────────────


def _handle_image(
    image: ET.Element,
    image_sink: ImageSink,
    warnings: list[str],
) -> str:
    data = image.find("one:Data", _NS)
    if data is None or not data.text:
        warnings.append("image without <one:Data> — skipped")
        return ""
    try:
        binary = base64.b64decode(data.text, validate=False)
    except Exception as exc:
        warnings.append(f"image base64 decode failed: {exc}")
        return ""

    fmt = (image.get("format") or "").lower().strip()
    ext = _ext_from_format(fmt) or _ext_from_signature(binary) or "png"
    digest = hashlib.sha1(binary).hexdigest()[:12]
    filename = f"img-{digest}.{ext}"

    try:
        return image_sink(binary, filename)
    except Exception as exc:
        warnings.append(f"image sink failed for {filename}: {exc}")
        return ""


def _handle_attachment(
    attached: ET.Element,
    image_sink: ImageSink,
    warnings: list[str],
) -> str:
    data = attached.find("one:Data", _NS)
    if data is None or not data.text:
        return ""
    try:
        binary = base64.b64decode(data.text, validate=False)
    except Exception as exc:
        warnings.append(f"attachment base64 decode failed: {exc}")
        return ""

    preferred = attached.get("preferredName") or "attachment.bin"
    # Keep original filename where possible.
    safe_name = re.sub(r'[\\/:*?"<>|]', "_", preferred)
    try:
        return image_sink(binary, safe_name)
    except Exception as exc:
        warnings.append(f"attachment sink failed for {safe_name}: {exc}")
        return ""


def _ext_from_format(fmt: str) -> str:
    mapping = {
        "png": "png", "jpg": "jpg", "jpeg": "jpg",
        "gif": "gif", "bmp": "bmp", "tiff": "tif", "tif": "tif",
    }
    return mapping.get(fmt, "")


def _ext_from_signature(binary: bytes) -> str:
    if len(binary) < 8:
        return ""
    # Common image magic bytes — enough to pick the right extension.
    if binary[:8] == b"\x89PNG\r\n\x1a\n":
        return "png"
    if binary[:3] == b"\xff\xd8\xff":
        return "jpg"
    if binary[:6] in (b"GIF87a", b"GIF89a"):
        return "gif"
    if binary[:2] == b"BM":
        return "bmp"
    if binary[:4] in (b"II*\x00", b"MM\x00*"):
        return "tif"
    return ""


# ── utility ──────────────────────────────────────────────────────────


def _strip_html(s: str) -> str:
    """Strip HTML tags, collapse whitespace. Used as markdownify fallback."""
    no_tags = re.sub(r"<[^>]+>", "", s)
    return re.sub(r"\s+", " ", no_tags).strip()


# ── frontmatter builder ──────────────────────────────────────────────


def build_frontmatter(
    *,
    notebook: str,
    section_path: list[str],
    page_id: str,
    last_modified: str,
    imported_at: str,
) -> str:
    """Build YAML frontmatter for an imported page. Returns the full block
    including the leading and trailing ``---`` lines and a trailing newline.
    """

    def esc(value: str) -> str:
        # Quote anything that needs it; simple YAML-safe escape.
        if value and (":" in value or "#" in value or value.startswith((" ", "-", "'", '"'))):
            escaped = value.replace('"', '\\"')
            return f'"{escaped}"'
        return value

    section_str = " / ".join(section_path) if section_path else ""
    lines = [
        "---",
        "source: onenote",
        f"notebook: {esc(notebook)}",
        f"section: {esc(section_str)}",
        f"page_id: {esc(page_id)}",
        f"last_modified: {esc(last_modified)}",
        f"imported_at: {esc(imported_at)}",
        "author: user",
        "---",
        "",
    ]
    return "\n".join(lines)
