"""OneNote import orchestrator.

Wires together:
    COMClient (read notebooks/sections/pages from the desktop app)
      → Converter (OneNote XML → Markdown, extracts images)
      → FileWriter (paths, collisions, state, atomic writes)

Emits ActivityLogger events + DecisionLogger records at every meaningful
boundary (Rule #13) so user-surfaced progress and post-hoc audit both work.
Per-page exceptions never abort the run — they're logged and counted.
"""

from __future__ import annotations

import asyncio
import logging
import re
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING

from . import file_writer as fw
from .com_client import OneNoteCOMClient, OneNoteCOMError, PageRef
from .converter import build_frontmatter, convert_page, extract_title
from .jobs import JobState

if TYPE_CHECKING:
    from agntspace.activity.decisions import DecisionLogger
    from agntspace.activity.logger import ActivityLogger


class ImportCancelled(Exception):
    """Cooperative cancellation signal from the job registry."""

log = logging.getLogger(__name__)


# ── meaningfulness filter ────────────────────────────────────────────
#
# Detects pages whose imported body would be either empty or pure
# template scaffolding. Mirrors the bucket A + bucket B logic used by
# the standalone cleanup script so the recurrence filter applies the
# same rule the user already approved for retroactive cleanup.

_HEADING_LINE_RE = re.compile(r"^#+\s+.*$", re.MULTILINE)
_BOILERPLATE_LINE_RE = re.compile(
    r"^\s*[-*]\s*$|^\s*TODO\s*:?\s*$|^\s*N/A\s*$",
    re.MULTILINE | re.IGNORECASE,
)
_NON_LETTER_RE = re.compile(r"[^a-zA-ZÀ-ÿĀ-žА-я\s]")
_DATE_HEADING_RE = re.compile(
    r"^#+\s+(\d{4}[-./]\d{1,2}[-./]\d{1,2}|\d{1,2}[-./]\d{1,2}[-./]\d{4})\s*$",
    re.MULTILINE,
)


def is_meaningless_body(body_md: str, title: str = "") -> tuple[bool, str]:
    """Return (is_meaningless, reason).

    Body is meaningless if any of:
      - empty / whitespace only ("empty_body")
      - only the title heading or a date heading ("heading_only")
      - non-heading content reduces to lone bullets / TODO markers
        ("boilerplate_only")

    Otherwise returns (False, "") — keep the page.
    """
    stripped = body_md.strip()
    if not stripped:
        return True, "empty_body"

    # Strip a leading H1 that just echoes the title — the importer prepends
    # ``# {title}\n\n`` to every page, so a page with no body content
    # presents as just that single H1 line.
    lines = [ln.rstrip() for ln in stripped.splitlines() if ln.strip()]
    if len(lines) == 1 and lines[0].startswith("#"):
        heading_text = lines[0].lstrip("#").strip()
        if (title and heading_text == title.strip()) or _DATE_HEADING_RE.match(lines[0]):
            return True, "heading_only"
        # Even a single non-matching heading line with no body is meaningless.
        return True, "heading_only"

    # Multi-line: strip every heading + every boilerplate line, see if anything
    # left contains real letters.
    without_headings = _HEADING_LINE_RE.sub("", stripped)
    cleaned = _BOILERPLATE_LINE_RE.sub("", without_headings)
    cleaned_letters = _NON_LETTER_RE.sub("", cleaned).strip()
    if not cleaned_letters:
        return True, "boilerplate_only"

    return False, ""


@dataclass
class ImportSummary:
    notebooks_imported: int = 0
    pages_imported: int = 0
    pages_skipped: int = 0
    pages_filtered_meaningless: int = 0
    errors: list[dict] = field(default_factory=list)
    target_folder: str = ""
    duration_s: float = 0.0

    def to_dict(self) -> dict:
        return {
            "notebooks_imported": self.notebooks_imported,
            "pages_imported": self.pages_imported,
            "pages_skipped": self.pages_skipped,
            "pages_filtered_meaningless": self.pages_filtered_meaningless,
            "errors": self.errors[:20],  # cap for response size
            "errors_count": len(self.errors),
            "target_folder": self.target_folder,
            "duration_s": round(self.duration_s, 2),
        }


class OneNoteImporter:
    """Single-run import orchestrator.

    Construction captures all the dependencies; ``run()`` does the work
    and returns an ``ImportSummary``. Safe to re-instantiate for each
    import; state is persisted in the target directory, not the object.
    """

    def __init__(
        self,
        *,
        vault_dir: Path,
        target_folder: str = "OneNote",
        notebooks_filter: list[str] | None = None,
        incremental: bool = True,
        skip_meaningless: bool = True,
        activity_logger: ActivityLogger | None = None,
        decisions: DecisionLogger | None = None,
        com_client_factory: object | None = None,
        job_state: JobState | None = None,
    ) -> None:
        self._vault_dir = Path(vault_dir)
        self._target_folder = target_folder.strip() or "OneNote"
        self._notebooks_filter = notebooks_filter
        self._incremental = incremental
        # When True, pages whose body is empty / heading-only / boilerplate-only
        # are filtered out at conversion time (no markdown written, no state
        # entry recorded). Mirrors bucket A + B logic from the user-approved
        # cleanup script so re-imports don't recreate the same noise.
        self._skip_meaningless = skip_meaningless
        self._activity = activity_logger
        self._decisions = decisions
        # Production code leaves ``com_client_factory`` unset and we dispatch
        # to the real ``OneNoteCOMClient``. Tests inject a stub factory so
        # they can run on any platform without pywin32 or the desktop app.
        self._com_client_factory = com_client_factory or OneNoteCOMClient
        # Live job state — callers poll this via /progress/{id} while the
        # import runs. Optional for direct/scripted usage; the plugin always
        # supplies one. JobState is a pure dataclass — mutations from this
        # worker thread are coherent enough for UX polling (reader tolerates
        # brief staleness by design).
        self._job = job_state

    @property
    def target_root(self) -> Path:
        return self._vault_dir / self._target_folder

    @property
    def state_path(self) -> Path:
        return self.target_root / ".onenote-state.json"

    async def run(self) -> ImportSummary:
        """Run the import. All COM work happens in a worker thread to keep
        the event loop responsive (COM calls can block for seconds)."""
        started = time.monotonic()
        summary = ImportSummary(target_folder=self._target_folder)
        if self._job is not None:
            self._job.mark_running()
        await self._activity_log(
            category="knowledge",
            action="onenote_import_started",
            summary=f"Importing OneNote notebooks into {self._target_folder}/",
            importance="medium",
            details={
                "target_folder": self._target_folder,
                "notebooks_filter": self._notebooks_filter,
                "incremental": self._incremental,
            },
        )

        # COM work runs in a thread pool — pywin32 calls block and we don't
        # want them blocking the FastAPI event loop. The converter and file
        # writer are pure-Python and fast; we keep them in the same thread
        # for locality (no back-and-forth to the event loop per page).
        try:
            summary = await asyncio.to_thread(self._run_sync, summary)
        except ImportCancelled:
            # Cancellation is a graceful state, not an error — the user
            # asked for it. The inner loop has already saved whatever state
            # it finished before the cancel point.
            summary.duration_s = time.monotonic() - started
            await self._activity_log(
                category="knowledge",
                action="onenote_import_cancelled",
                summary=(
                    f"Import cancelled after {summary.pages_imported} imported,"
                    f" {summary.pages_skipped} skipped"
                ),
                importance="medium",
                details=summary.to_dict(),
            )
            if self._job is not None:
                self._job.mark_cancelled(summary.to_dict())
            await self._record_decision(
                summary, contract_result="cancelled", rationale="user cancel"
            )
            return summary
        except OneNoteCOMError as exc:
            summary.errors.append({"stage": "connect", "error": str(exc)})
            await self._activity_log(
                category="knowledge",
                action="onenote_import_failed",
                summary=f"OneNote COM connect failed: {exc}",
                importance="high",
                details={"error": str(exc)},
            )
            summary.duration_s = time.monotonic() - started
            if self._job is not None:
                self._job.mark_failed(str(exc), summary.to_dict())
            await self._record_decision(summary, contract_result="failed", rationale=str(exc))
            return summary
        except Exception as exc:  # noqa: BLE001 — top-level safety net
            log.exception("onenote-import: unexpected failure")
            summary.errors.append({"stage": "run", "error": str(exc)})
            await self._activity_log(
                category="knowledge",
                action="onenote_import_failed",
                summary=f"OneNote import failed: {exc}",
                importance="high",
                details={"error": str(exc), "error_type": type(exc).__name__},
            )
            summary.duration_s = time.monotonic() - started
            if self._job is not None:
                self._job.mark_failed(str(exc), summary.to_dict())
            await self._record_decision(summary, contract_result="failed", rationale=str(exc))
            return summary

        summary.duration_s = time.monotonic() - started
        await self._activity_log(
            category="knowledge",
            action="onenote_import_completed",
            summary=(
                f"Imported {summary.pages_imported} pages"
                f" (skipped {summary.pages_skipped}, errors {len(summary.errors)})"
            ),
            importance="medium",
            details=summary.to_dict(),
        )
        if self._job is not None:
            self._job.mark_completed(summary.to_dict())
        await self._record_decision(summary, contract_result="allowed")
        return summary

    # ── sync body (runs in worker thread) ────────────────────────────

    def _run_sync(self, summary: ImportSummary) -> ImportSummary:
        state = fw.load_state(self.state_path)
        self.target_root.mkdir(parents=True, exist_ok=True)

        with self._com_client_factory() as client:
            pages: list[PageRef] = client.list_pages(
                notebooks_filter=self._notebooks_filter
            )
            if not pages:
                return summary

            # Populate denominators for the /progress response as soon as
            # we know them — the user sees the scope of the job (total
            # pages, total notebooks) immediately after it starts.
            notebooks_seen: set[str] = set()
            if self._job is not None:
                self._job.pages_total = len(pages)
                self._job.notebooks_total = len(
                    {p.notebook_id or p.notebook for p in pages}
                )

            for i, page_ref in enumerate(pages):
                # Cooperative cancellation — checked between pages so a
                # long-running import stops within at most one page's work.
                if self._job is not None and self._job.cancel_requested:
                    # Persist state so partial progress isn't lost.
                    try:
                        fw.save_state(self.state_path, state)
                    except OSError:
                        log.debug("onenote-import: state save at cancel failed", exc_info=True)
                    summary.notebooks_imported = len(notebooks_seen)
                    raise ImportCancelled()

                notebooks_seen.add(page_ref.notebook_id or page_ref.notebook)
                if self._job is not None:
                    self._job.set_current(
                        page_ref.notebook,
                        page_ref.section_path,
                        page_ref.page_name,
                    )
                try:
                    action = self._process_page(page_ref, state, client)
                except Exception as exc:  # noqa: BLE001
                    log.warning(
                        "onenote-import: page failed: %s / %s / %s: %s",
                        page_ref.notebook,
                        "/".join(page_ref.section_path),
                        page_ref.page_name,
                        exc,
                    )
                    err_entry = {
                        "notebook": page_ref.notebook,
                        "section": "/".join(page_ref.section_path),
                        "page": page_ref.page_name,
                        "error": str(exc),
                    }
                    summary.errors.append(err_entry)
                    if self._job is not None:
                        self._job.record_error(err_entry)
                    continue

                if action == "imported":
                    summary.pages_imported += 1
                    if self._job is not None:
                        self._job.inc_imported()
                elif action == "skipped":
                    summary.pages_skipped += 1
                    if self._job is not None:
                        self._job.inc_skipped()
                elif action == "filtered_meaningless":
                    # Counted under both pages_skipped (top-line UX) and the
                    # dedicated pages_filtered_meaningless field (analytics).
                    summary.pages_skipped += 1
                    summary.pages_filtered_meaningless += 1
                    if self._job is not None:
                        self._job.inc_skipped()

                # Batched progress pulse — fires at medium importance so
                # the background-work toaster surfaces it to the user.
                # Per-page events (fired inside _process_page) stay at
                # low importance for the observations file only.
                # Adaptive batch size: keep ~30 toasts per import
                # regardless of notebook size — 10 for 300 pages, 33 for
                # 1000, 167 for 5000 — avoids toast spam on huge vaults.
                self._maybe_fire_progress_pulse(page_ref.notebook)

                # Persist state every 25 pages so a mid-run crash costs
                # at most 25 pages of redo work.
                if (i + 1) % 25 == 0:
                    try:
                        fw.save_state(self.state_path, state)
                    except OSError:
                        log.debug("onenote-import: periodic state save failed", exc_info=True)

            summary.notebooks_imported = len(notebooks_seen)

        # Final state write — best-effort, no-raise.
        try:
            fw.save_state(self.state_path, state)
        except OSError as exc:
            summary.errors.append({"stage": "state_save", "error": str(exc)})
            log.warning("onenote-import: state save failed: %s", exc)

        return summary

    def _process_page(
        self,
        page_ref: PageRef,
        state: fw.ImportState,
        client: OneNoteCOMClient,
    ) -> str:
        """Import a single page. Returns "imported" / "skipped".
        Raises on unrecoverable per-page errors (caller logs + continues)."""
        existing = state.get(page_ref.page_id)
        if (
            self._incremental
            and existing
            and existing.get("last_modified") == page_ref.last_modified
            and page_ref.last_modified
        ):
            # Sanity check — file must still exist on disk; if a user deleted
            # the markdown we want to re-write it even though state matches.
            sec = fw.section_dir(
                self.target_root, page_ref.notebook, page_ref.section_path
            )
            if (sec / f"{existing['filename']}.md").exists():
                return "skipped"

        xml = client.get_page_xml(page_ref.page_id, with_binary=True)

        # Prefer the title from the page XML (authoritative — users can edit
        # titles in-page and the hierarchy view sometimes lags). Fall back
        # to the hierarchy-reported page name.
        title = extract_title(xml) or page_ref.page_name

        filename = fw.resolve_page_filename(
            target_root=self.target_root,
            notebook=page_ref.notebook,
            section_path=page_ref.section_path,
            page_title=title,
            page_id=page_ref.page_id,
            state=state,
        )

        def image_sink(binary: bytes, suggested_name: str) -> str:
            return fw.write_image(
                target_root=self.target_root,
                notebook=page_ref.notebook,
                section_path=page_ref.section_path,
                filename=suggested_name,
                binary=binary,
            )

        converted = convert_page(xml, image_sink)

        # Recurrence filter — drop pages whose body would land in bucket A
        # (empty / heading-only) or bucket B (boilerplate-only) of the user
        # cleanup model. Runs on the converted markdown body BEFORE we touch
        # the filesystem or the state file: a filtered page leaves no trace.
        # ``existing`` may be set when re-importing a previously-imported page
        # whose source has since been emptied — the filter still removes it
        # from the active import (the on-disk file from a prior run remains
        # untouched; the user can clean those with the standalone tool).
        if self._skip_meaningless:
            meaningless, reason = is_meaningless_body(converted.markdown, title)
            if meaningless:
                if self._activity is not None:
                    try:
                        self._activity.log(
                            category="knowledge",
                            action="onenote_page_filtered",
                            summary=f"Filtered meaningless page: {page_ref.notebook} / {title}",
                            details={
                                "notebook": page_ref.notebook,
                                "section": "/".join(page_ref.section_path),
                                "page": title,
                                "reason": reason,
                            },
                            importance="low",
                        )
                    except Exception:
                        log.debug("onenote-import: filter activity log failed", exc_info=True)
                return "filtered_meaningless"

        frontmatter = build_frontmatter(
            notebook=page_ref.notebook,
            section_path=page_ref.section_path,
            page_id=page_ref.page_id,
            last_modified=page_ref.last_modified,
            imported_at=datetime.now(UTC).isoformat(),
        )

        # Prepend title as a markdown H1 so the imported page has a visible
        # heading in the markdown too (frontmatter alone is invisible when
        # rendered).
        md = f"{frontmatter}# {title}\n\n{converted.markdown}"

        fw.write_page(
            target_root=self.target_root,
            notebook=page_ref.notebook,
            section_path=page_ref.section_path,
            filename=filename,
            markdown=md,
        )
        state.put(
            page_ref.page_id,
            filename=filename,
            last_modified=page_ref.last_modified,
        )

        # Fire a low-importance per-page event. Importance is 'low' on
        # purpose — hundreds of pages would spam the user's observations
        # file at medium+. The completion event aggregates counts.
        #
        # Not awaited — we're in a worker thread and ActivityLogger.log is
        # sync; call directly.
        if self._activity is not None:
            try:
                self._activity.log(
                    category="knowledge",
                    action="onenote_page_imported",
                    summary=f"Imported {page_ref.notebook} / {title}",
                    details={
                        "notebook": page_ref.notebook,
                        "section": "/".join(page_ref.section_path),
                        "page": title,
                        "images": len(converted.images_saved),
                        "warnings": converted.warnings,
                    },
                    importance="low",
                )
            except Exception:
                log.debug("onenote-import: activity log failed", exc_info=True)

        return "imported"

    def _maybe_fire_progress_pulse(self, notebook: str) -> None:
        """Fire a batched progress event if we're at a batch boundary.

        Medium importance so the /activity toaster surfaces it. Adaptive
        batch size (~30 pulses per import regardless of notebook size) to
        keep toast cadence sensible on both small and huge vaults.

        Runs in the importer's worker thread — ActivityLogger.log is sync
        so we call it directly without a task hop.
        """
        if self._job is None or self._activity is None:
            return
        processed = self._job.pages_processed
        total = self._job.pages_total
        if total <= 0 or processed <= 0:
            return
        batch = max(10, total // 30)
        if processed % batch != 0 and processed != total:
            return
        try:
            self._activity.log(
                category="knowledge",
                action="onenote_import_progress",
                summary=(
                    f"OneNote: imported {self._job.pages_imported}/{total} pages "
                    f"from {notebook} ({processed} processed, {len(self._job.errors)} errors)"
                ),
                details={
                    "notebook": notebook,
                    "pages_total": total,
                    "pages_processed": processed,
                    "pages_imported": self._job.pages_imported,
                    "pages_skipped": self._job.pages_skipped,
                    "errors_count": len(self._job.errors),
                    "progress_ratio": round(processed / total, 3),
                },
                importance="medium",
            )
        except Exception:
            log.debug("onenote-import: progress pulse log failed", exc_info=True)

    # ── observability helpers ────────────────────────────────────────

    async def _activity_log(
        self,
        *,
        category: str,
        action: str,
        summary: str,
        importance: str,
        details: dict | None = None,
    ) -> None:
        if self._activity is None:
            return
        try:
            self._activity.log(
                category=category,
                action=action,
                summary=summary,
                details=details or {},
                importance=importance,
            )
        except Exception:
            log.debug("onenote-import: activity log failed", exc_info=True)

    async def _record_decision(
        self,
        summary: ImportSummary,
        *,
        contract_result: str,
        rationale: str = "",
    ) -> None:
        if self._decisions is None:
            return
        try:
            await self._decisions.record(
                actor="plugin:onenote-import",
                action_type="onenote_import",
                action_target=self._target_folder,
                contract_action="write_vault",
                contract_result=contract_result,
                triggered_by="plugin",
                rationale=rationale or self._rationale(summary),
                details=summary.to_dict(),
            )
        except Exception:
            log.debug("onenote-import: decision record failed", exc_info=True)

    def _rationale(self, summary: ImportSummary) -> str:
        parts = [
            f"imported {summary.pages_imported}",
            f"skipped {summary.pages_skipped}",
        ]
        if summary.errors:
            parts.append(f"errors {len(summary.errors)}")
        if self._notebooks_filter:
            parts.append(f"filter={self._notebooks_filter}")
        if not self._incremental:
            parts.append("full-reimport")
        return "; ".join(parts)
