"""OneNote COM client — PowerShell subprocess bridge.

The Microsoft 365 version of OneNote (v16.x, Office 2019+) exposes its
COM interface via the ``Application2Class`` coclass, whose ``IDispatch``
fails ``GetTypeInfo()`` with E_FAIL and whose ``GetIDsOfNames("GetHierarchy")``
returns ``DISP_E_UNKNOWNNAME`` under pywin32's dynamic dispatch. Early
binding via ``gencache.EnsureDispatch`` also fails because pywin32 can't
generate makepy stubs without type info. The OneNote 2016 standalone
build had these facilities; recent M365 builds quietly stripped them.

PowerShell's .NET COM interop layer works correctly here (it finds the
type library through the runtime class registration) so we shell out to
``powershell.exe`` for the actual COM calls. The subprocess overhead
(~150-200ms per invocation) is acceptable — ``GetHierarchy`` is called
once per import, ``GetPageContent`` once per page.

No pywin32 dependency. PowerShell ships with every supported Windows.

Windows-only. On other platforms, ``connect()`` raises early.
"""

from __future__ import annotations

import logging
import subprocess
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field

log = logging.getLogger(__name__)

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

# Hierarchy scope constants — match the OneNote COM type library.
HS_SELF = 0
HS_CHILDREN = 1
HS_NOTEBOOKS = 2
HS_SECTIONS = 3
HS_PAGES = 4

# PageInfo enum for GetPageContent.
PI_BASIC = 0
PI_BINARY_DATA = 1
PI_ALL = 2
PI_SELECTION = 3
PI_BINARY_DATA_SELECTION = 4

# PowerShell subprocess timeout in seconds. GetHierarchy on a large
# notebook tree can take ~10s; GetPageContent with binary data ~5s.
# A hard 60s cap prevents a stuck COM call from hanging the importer.
_PS_TIMEOUT = 60


class OneNoteCOMError(Exception):
    """Raised when the PowerShell bridge can't complete a OneNote call."""


@dataclass
class PageRef:
    notebook: str
    notebook_id: str
    section_path: list[str]
    section_id: str
    page_name: str
    page_id: str
    last_modified: str = ""


@dataclass
class Hierarchy:
    notebooks: list[dict] = field(default_factory=list)
    pages: list[PageRef] = field(default_factory=list)


class OneNoteCOMClient:
    """Thin wrapper over a PowerShell-based OneNote COM bridge.

    Interface is unchanged from the pywin32 era so ``importer.py`` and
    tests keep working:

        client = OneNoteCOMClient()
        client.connect()           # raises OneNoteCOMError if OneNote missing
        pages = client.list_pages()
        xml = client.get_page_xml(page.page_id)
        client.close()

    ``connect()`` runs a tiny probe script to verify PowerShell + COM are
    both wired. It's cheap (<200ms) and catches broken installs early.
    """

    def __init__(self) -> None:
        self._connected = False

    def connect(self) -> None:
        """Verify PowerShell + OneNote COM are both available."""
        if sys.platform != "win32":
            raise OneNoteCOMError(
                "OneNote COM is Windows-only. This platform is "
                f"{sys.platform!r}."
            )
        # Tiny probe: instantiate the COM object and read its type name.
        # This fails fast if PowerShell is missing, execution-policy-blocked,
        # or if OneNote isn't installed.
        probe = (
            "$onenote = New-Object -ComObject OneNote.Application; "
            "Write-Output $onenote.GetType().Name"
        )
        try:
            result = _run_powershell(probe, timeout=15)
        except OneNoteCOMError:
            raise
        type_name = result.strip()
        if "Application" not in type_name:
            raise OneNoteCOMError(
                "OneNote.Application COM probe returned unexpected type: "
                f"{type_name!r}. Is the OneNote desktop app installed? "
                "(OneNote for Windows 10 / UWP does not expose COM.)"
            )
        log.info("onenote-import: connected via PowerShell (%s)", type_name)
        self._connected = True

    def close(self) -> None:
        self._connected = False

    def __enter__(self) -> OneNoteCOMClient:
        self.connect()
        return self

    def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
        self.close()

    # ── hierarchy ────────────────────────────────────────────────────

    def list_pages(
        self,
        *,
        notebooks_filter: list[str] | None = None,
    ) -> list[PageRef]:
        xml = self._get_hierarchy_xml()
        return parse_hierarchy(xml, notebooks_filter=notebooks_filter)

    def _get_hierarchy_xml(self, start_node: str = "", scope: int = HS_PAGES) -> str:
        if not self._connected:
            raise OneNoteCOMError("Not connected. Call connect() first.")
        # PowerShell call: COM method takes (in start, in scope, out xml)
        # The [ref] binding is PS's way of passing the [out] BSTR.
        #
        # We write the XML to a StreamWriter that disables BOM and uses
        # UTF-8 so stdout piping preserves multi-byte characters (CJK
        # page titles, accented names, etc.) intact.
        script = (
            "$onenote = New-Object -ComObject OneNote.Application; "
            "$xml = $null; "
            f"$onenote.GetHierarchy('{_ps_escape(start_node)}', {int(scope)}, [ref]$xml); "
            "[System.Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); "
            "[System.Console]::Out.Write($xml)"
        )
        try:
            xml = _run_powershell(script, timeout=_PS_TIMEOUT)
        except OneNoteCOMError as exc:
            raise OneNoteCOMError(f"GetHierarchy failed: {exc}") from exc
        if not xml or not xml.strip():
            raise OneNoteCOMError("GetHierarchy returned empty output")
        return xml

    # ── page content ─────────────────────────────────────────────────

    def get_page_xml(self, page_id: str, *, with_binary: bool = True) -> str:
        if not self._connected:
            raise OneNoteCOMError("Not connected. Call connect() first.")
        info = PI_BINARY_DATA if with_binary else PI_BASIC
        script = (
            "$onenote = New-Object -ComObject OneNote.Application; "
            "$xml = $null; "
            f"$onenote.GetPageContent('{_ps_escape(page_id)}', [ref]$xml, {int(info)}); "
            "[System.Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); "
            "[System.Console]::Out.Write($xml)"
        )
        try:
            xml = _run_powershell(script, timeout=_PS_TIMEOUT)
        except OneNoteCOMError as exc:
            raise OneNoteCOMError(
                f"GetPageContent failed for page {page_id}: {exc}"
            ) from exc
        if not xml or not xml.strip():
            raise OneNoteCOMError(
                f"GetPageContent returned empty output for page {page_id}"
            )
        return xml


# ── PowerShell bridge ────────────────────────────────────────────────


def _ps_escape(s: str) -> str:
    """Escape a string for embedding inside PowerShell single quotes.

    PowerShell single-quoted strings only need '' as an escape for a
    single quote. Nothing else needs escaping inside single quotes.
    """
    return s.replace("'", "''")


def _run_powershell(script: str, *, timeout: int) -> str:
    """Run a PowerShell script and return its stdout as UTF-8 text.

    Raises OneNoteCOMError on non-zero exit, timeout, or stderr output.
    ``-NoProfile`` skips the user's PS profile for speed + determinism.
    ``-ExecutionPolicy Bypass`` handles locked-down default policies.
    """
    try:
        result = subprocess.run(
            [
                "powershell", "-NoProfile", "-NonInteractive",
                "-ExecutionPolicy", "Bypass",
                "-Command", script,
            ],
            capture_output=True,
            timeout=timeout,
            # CREATE_NO_WINDOW prevents a console window flashing on each
            # subprocess call — OneNote pages are called dozens of times.
            creationflags=0x08000000 if sys.platform == "win32" else 0,
        )
    except subprocess.TimeoutExpired as exc:
        raise OneNoteCOMError(
            f"PowerShell call timed out after {timeout}s"
        ) from exc
    except FileNotFoundError as exc:
        raise OneNoteCOMError(
            "powershell.exe not found on PATH. PowerShell ships with "
            "Windows; a missing install is unusual."
        ) from exc

    stdout = result.stdout.decode("utf-8", errors="replace")
    stderr = result.stderr.decode("utf-8", errors="replace")

    if result.returncode != 0:
        # PowerShell puts useful diagnostic in stderr; include it in the error.
        err = stderr.strip() or "(no stderr)"
        raise OneNoteCOMError(
            f"PowerShell exited {result.returncode}: {err}"
        )
    # Non-fatal stderr warnings shouldn't fail the call, but empty stdout
    # with non-empty stderr almost always means the COM call threw.
    if not stdout.strip() and stderr.strip():
        raise OneNoteCOMError(f"PowerShell returned no output; stderr: {stderr.strip()}")
    return stdout


# ── pure parsers (unit-testable without COM) ─────────────────────────


def parse_hierarchy(
    xml: str,
    *,
    notebooks_filter: list[str] | None = None,
) -> list[PageRef]:
    """Parse hierarchy XML into a flat list of PageRefs.

    Pure function — testable with fixture XML.
    """
    filter_lower = {n.strip().lower() for n in notebooks_filter} if notebooks_filter else None
    root = ET.fromstring(xml)
    notebooks: list[ET.Element]
    if _localname(root) == "Notebooks":
        notebooks = list(root.findall("one:Notebook", _NS))
    elif _localname(root) == "Notebook":
        notebooks = [root]
    else:
        notebooks = list(root.findall(".//one:Notebook", _NS))

    pages: list[PageRef] = []
    for nb in notebooks:
        nb_name = nb.get("name") or "Untitled Notebook"
        nb_id = nb.get("ID") or ""
        if filter_lower and nb_name.strip().lower() not in filter_lower:
            continue
        _walk_sections(nb, nb_name, nb_id, section_path=[], pages=pages)
    return pages


def _walk_sections(
    parent: ET.Element,
    nb_name: str,
    nb_id: str,
    *,
    section_path: list[str],
    pages: list[PageRef],
) -> None:
    for section in parent.findall("one:Section", _NS):
        if _bool_attr(section, "isPasswordProtected"):
            log.debug(
                "Skipping password-protected section: %s/%s",
                nb_name, section.get("name"),
            )
            continue
        if _bool_attr(section, "isInRecycleBin"):
            continue
        sec_name = section.get("name") or "Untitled Section"
        sec_id = section.get("ID") or ""
        for page in section.findall("one:Page", _NS):
            if _bool_attr(page, "isInRecycleBin"):
                continue
            pages.append(
                PageRef(
                    notebook=nb_name,
                    notebook_id=nb_id,
                    section_path=[*section_path, sec_name],
                    section_id=sec_id,
                    page_name=page.get("name") or "Untitled Page",
                    page_id=page.get("ID") or "",
                    last_modified=page.get("lastModifiedTime") or "",
                )
            )

    for group in parent.findall("one:SectionGroup", _NS):
        if _bool_attr(group, "isInRecycleBin"):
            continue
        if _bool_attr(group, "isRecycleBin"):
            continue
        group_name = group.get("name") or "Untitled Group"
        _walk_sections(
            group, nb_name, nb_id,
            section_path=[*section_path, group_name], pages=pages,
        )


def _localname(elem: ET.Element) -> str:
    tag = elem.tag
    if "}" in tag:
        return tag.split("}", 1)[1]
    return tag


def _bool_attr(elem: ET.Element, name: str) -> bool:
    val = elem.get(name)
    return bool(val) and val.strip().lower() == "true"
