"""Shared helpers and literal types for MCP mega-tools and the agent CLI."""

from __future__ import annotations

import concurrent.futures
import json
import os
import re
from pathlib import Path
from typing import Any, Literal, cast

from ..graph.markdown_blocks import locate_block_by_uuid
from ..graph.path_sandbox import graph_safe_page_path, read_graph_file_text
from ..utils.json_repair import loads_repaired_json
from ..utils.regex_policy import validate_regex_pattern
from .page_input_normalizer import (
    format_resolution_notes_footer,
    normalize_page_ref_or_raw,
    normalize_pipe_page_target,
)

MAX_REGEX_SCAN_BYTES = 5_000_000
_REGEX_SCAN_TIMEOUT_SECONDS = 5.0

ReadGraphTarget = Literal[
    "page",
    "memory",
    "block_ast",
    "subtree",
    "structural_hops",
    "dashboard",
    "xray_page",
    "bootstrap_status",
]
SearchGraphMethod = Literal[
    "bm25",
    "semantic",
    "regex",
    "unlinked_mentions",
    "journal_tasks",
    "resolve_entity",
]
MutateGraphAction = Literal["write_outline", "edit_property", "append_journal", "inject_query"]
RefactorBlocksAction = Literal["split_large", "reparent", "generate_flashcards"]
RunLinterName = Literal["unify_tags", "block_refs", "full_wiki_scan"]


def graph_path_from_env() -> str:
    return os.environ.get("LOGSEQ_GRAPH_PATH", "").strip()


def graph_missing_text() -> str:
    return (
        "LOGSEQ_GRAPH_PATH is not set; cannot access the graph on disk. "
        "Set it to your Logseq graph root (the folder that contains `pages/`), then retry."
    )


def graph_missing_dict() -> dict[str, Any]:
    return {"ok": False, "code": "graph_missing", "hint": graph_missing_text()}


def parse_json_object(payload: str, *, field_name: str = "payload") -> dict[str, Any]:
    raw = payload.strip()
    if not raw:
        msg = f"`{field_name}` must be a non-empty JSON object"
        raise ValueError(msg)
    data = loads_repaired_json(raw) if raw.startswith("{") else json.loads(raw)
    if not isinstance(data, dict):
        msg = f"`{field_name}` must decode to a JSON object"
        raise TypeError(msg)
    return cast(dict[str, Any], data)


def parse_optional_json_query(query: str) -> dict[str, Any]:
    raw = query.strip()
    if not raw:
        return {}
    if raw.startswith("{"):
        data = loads_repaired_json(raw)
        if not isinstance(data, dict):
            msg = "`query` JSON must be an object when it starts with `{`"
            raise TypeError(msg)
        return cast(dict[str, Any], data)
    return {}


def bounded_int_from_options(
    opts: dict[str, Any],
    key: str,
    *,
    default: int,
    minimum: int,
    maximum: int,
) -> int | str:
    """Return a clamped integer or a human-readable validation error string."""
    if key not in opts or opts[key] is None:
        return max(minimum, min(default, maximum))
    raw = opts[key]
    if isinstance(raw, bool):
        return f"Invalid integer for `{key}`: {raw!r}"
    try:
        value = int(raw)
    except (TypeError, ValueError):
        return f"Invalid integer for `{key}`: {raw!r}"
    return max(minimum, min(value, maximum))


def _persistable_block_uuid(node: object) -> str:
    """Prefer on-disk ``id::`` (``source_uuid``) over parser session ``uuid``."""
    source_uuid = getattr(node, "source_uuid", None)
    if isinstance(source_uuid, str) and source_uuid.strip():
        return source_uuid.strip()
    node_uuid = getattr(node, "uuid", None)
    if isinstance(node_uuid, str) and node_uuid.strip():
        return node_uuid.strip()
    msg = "Parsed block is missing both `source_uuid` and `uuid`"
    raise ValueError(msg)


def _persistable_alias_map(
    roots: list[object],
    parser_alias_map: dict[int, str],
) -> dict[int, str]:
    """Map session aliases to UUIDs safe for on-disk ``id::`` lookups."""
    by_parser_uuid: dict[str, object] = {}

    def walk(node: object) -> None:
        parser_uuid = getattr(node, "uuid", None)
        if isinstance(parser_uuid, str) and parser_uuid:
            by_parser_uuid[parser_uuid] = node
        for child in getattr(node, "children", None) or []:
            walk(child)

    for root in roots:
        walk(root)

    persistable: dict[int, str] = {}
    for alias, parser_uuid in parser_alias_map.items():
        node = by_parser_uuid.get(parser_uuid)
        if node is None:
            continue
        persistable[alias] = _persistable_block_uuid(node)
    return persistable


def read_xray_page_markdown(graph_path: str, page_name: str) -> str:
    """Parse a page, assign ``[n]`` aliases, persist them, and return X-Ray markdown."""
    from logseq_matryca_parser.agent_press import SessionAliasRegistry, to_xray_markdown

    from ..rag.matryca_hooks import get_spatial_context, resolve_logseq_page_md
    from .alias_state import alias_file_path, safe_update_alias, save_alias_registry

    title = page_name.strip()
    if not title:
        msg = "For `target_type=xray_page`, set `query` to the Logseq page title."
        raise ValueError(msg)

    try:
        page_norm = normalize_page_ref_or_raw(graph_path, title)
    except ValueError as exc:
        msg = str(exc)
        raise ValueError(msg) from exc
    path = resolve_logseq_page_md(graph_path, page_norm.canonical_title)
    title = page_norm.canonical_title
    parsed = get_spatial_context(str(path))
    roots = getattr(parsed, "root_nodes", None) or []
    if not roots:
        return f"# X-Ray: [[{title}]]\n\n_Empty page — no outline blocks to alias._\n"

    registry = SessionAliasRegistry()
    parser_alias_map = registry.generate_aliases(roots)
    body = to_xray_markdown(roots, registry)
    persistable = _persistable_alias_map(roots, parser_alias_map)
    persist_registry = SessionAliasRegistry()
    for alias, block_uuid in persistable.items():
        safe_update_alias(persist_registry, alias, block_uuid)
    save_alias_registry(graph_path, persist_registry)
    alias_count = len(persistable)
    state_name = alias_file_path(graph_path).name
    header = (
        f"# X-Ray: [[{title}]]\n\n"
        f"**Aliases:** {alias_count} block(s) mapped to `[0]`…`[{alias_count - 1}]` "
        f"in `{state_name}` at the graph root. "
        "Pass `[n]` as `target` or in `Page Title|[n]` for `mutate_graph` / `refactor_blocks`.\n\n"
        f"{body}\n"
    )
    return header + format_resolution_notes_footer(page_norm.resolution_notes)


def read_subtree_markdown(graph_path: str, query: str) -> str:
    """Return Markdown for a block subtree; optional ``# Heading`` filter in JSON query."""
    from .alias_state import resolve_pipe_target

    try:
        normalized_query, page_notes = normalize_pipe_page_target(graph_path, query)
    except ValueError as exc:
        msg = str(exc)
        raise ValueError(msg) from exc
    resolved_query = resolve_pipe_target(graph_path, normalized_query)
    opts: dict[str, Any] = {}
    page_ref = resolved_query
    block_uuid = ""
    if resolved_query.strip().startswith("{"):
        opts = parse_optional_json_query(resolved_query)
        raw_page = str(opts.get("page", "")).strip()
        page_norm = normalize_page_ref_or_raw(graph_path, raw_page) if raw_page else None
        page_ref = page_norm.canonical_title if page_norm else ""
        if page_norm:
            page_notes.extend(page_norm.resolution_notes)
        block_uuid = str(opts.get("block_uuid", opts.get("uuid", ""))).strip()
    else:
        parts = [p.strip() for p in resolved_query.split("|", 1)]
        if len(parts) == 2:
            page_ref, block_uuid = parts[0], parts[1]
    heading_filter = str(opts.get("heading", "")).strip() if opts else ""

    if not page_ref or not block_uuid:
        msg = (
            "Invalid subtree query. Use `Page Title|block-uuid` or JSON "
            '`{"page":"...","block_uuid":"...","heading":"optional"}`.'
        )
        raise ValueError(msg)

    path = graph_safe_page_path(graph_path, page_ref)
    text = read_graph_file_text(path, graph_path, errors="replace")
    lines = text.splitlines(keepends=True)
    stripped = [ln.rstrip("\n") for ln in lines]
    loc = locate_block_by_uuid(stripped, block_uuid)
    if loc is None:
        return (
            f"Block `{block_uuid}` not found on page `{page_ref}`. "
            "Confirm the UUID matches an `id::` line on that page."
        )
    b_idx, _id_idx, end = loc
    excerpt_lines = lines[b_idx:end]
    if heading_filter:
        heading_needle = heading_filter.lstrip("#").strip().lower()
        filtered: list[str] = []
        include = False
        bullet_match = re.compile(r"^(\s*)-\s+(.*)$")
        heading_indent: int | None = None
        for line in excerpt_lines:
            stripped_line = line.rstrip("\n")
            match = bullet_match.match(stripped_line)
            if match:
                indent = len(match.group(1))
                text_part = match.group(2).strip()
                if not include:
                    if text_part.lstrip("#").strip().lower() == heading_needle:
                        include = True
                        heading_indent = indent
                        filtered = [line]
                    continue
                if heading_indent is not None and indent > heading_indent:
                    filtered.append(line)
                else:
                    break
            elif include:
                filtered.append(line)
        excerpt_lines = filtered or excerpt_lines

    excerpt = "".join(excerpt_lines)
    body = (
        f"# Subtree excerpt\n\n"
        f"- **Page:** [[{page_ref}]]\n"
        f"- **Block UUID:** `{block_uuid}`\n\n"
        f"```markdown\n{excerpt.rstrip()}\n```\n"
    )
    return body + format_resolution_notes_footer(page_notes)


def read_block_ast_markdown(graph_path: str, query: str) -> str:
    """Return the on-disk Markdown subtree for one block (page title + ``id::`` UUID)."""
    from .alias_state import resolve_pipe_target

    try:
        normalized_query, page_notes = normalize_pipe_page_target(graph_path, query)
    except ValueError as exc:
        msg = str(exc)
        raise ValueError(msg) from exc
    resolved_query = resolve_pipe_target(graph_path, normalized_query)
    parts = [p.strip() for p in resolved_query.split("|", 1)]
    if len(parts) != 2 or not parts[0] or not parts[1]:
        msg = (
            f"Invalid block_ast query format: {query!r}. "
            "Expected `Page Title|block-uuid` or `Page Title|[n]` (X-Ray alias)."
        )
        raise ValueError(msg)
    page_ref, block_uuid = parts[0], parts[1]
    path = graph_safe_page_path(graph_path, page_ref)
    text = read_graph_file_text(path, graph_path, errors="replace")
    lines = text.splitlines(keepends=True)
    stripped = [ln.rstrip("\n") for ln in lines]
    loc = locate_block_by_uuid(stripped, block_uuid)
    if loc is None:
        return (
            f"Block `{block_uuid}` not found on page `{page_ref}`. "
            "Confirm the UUID matches an `id::` line on that page."
        )
    b_idx, _id_idx, end = loc
    excerpt = "".join(lines[b_idx:end])
    body = (
        f"# Block AST excerpt\n\n"
        f"- **Page:** [[{page_ref}]]\n"
        f"- **Block UUID:** `{block_uuid}`\n\n"
        f"```markdown\n{excerpt.rstrip()}\n```\n"
    )
    return body + format_resolution_notes_footer(page_notes)


def _scan_pages_for_regex(
    graph_path: str,
    compiled: re.Pattern[str],
    *,
    limit: int,
) -> list[tuple[str, int, str]]:
    from ..graph.alias_index import is_scannable_graph_markdown

    root = Path(graph_path).expanduser().resolve(strict=False)
    pages = root / "pages"
    if not pages.is_dir():
        return []

    hits: list[tuple[str, int, str]] = []
    scanned_bytes = 0
    for path in sorted(pages.rglob("*.md")):
        if not path.is_file() or not is_scannable_graph_markdown(path, root):
            continue
        try:
            body = read_graph_file_text(path, root, errors="replace")
        except OSError:
            continue
        scanned_bytes += len(body.encode("utf-8", errors="replace"))
        if scanned_bytes > MAX_REGEX_SCAN_BYTES:
            break
        rel = path.relative_to(root).as_posix()
        for line_no, line in enumerate(body.splitlines(), start=1):
            if compiled.search(line):
                hits.append((rel, line_no, line.strip()[:240]))
                if len(hits) >= limit:
                    return hits
        if len(hits) >= limit:
            break
    return hits


def format_regex_search_markdown(graph_path: str, pattern: str, *, limit: int = 50) -> str:
    """Vault-wide ``pages/**/*.md`` line scan (orchestration; not the spatial parser)."""
    try:
        compiled = validate_regex_pattern(pattern)
    except ValueError as exc:
        raise ValueError(f"Invalid regex in `query`: {exc}") from exc

    root = Path(graph_path).expanduser().resolve(strict=False)
    pages = root / "pages"
    if not pages.is_dir():
        return f"{graph_missing_text()}\n\n`pages/` directory is missing."

    with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
        future = pool.submit(_scan_pages_for_regex, graph_path, compiled, limit=limit)
        try:
            hits = future.result(timeout=_REGEX_SCAN_TIMEOUT_SECONDS)
        except concurrent.futures.TimeoutError as exc:
            msg = f"regex scan timed out after {_REGEX_SCAN_TIMEOUT_SECONDS}s"
            raise ValueError(msg) from exc

    lines = [
        "# Regex search (pages/)",
        "",
        f"- **Graph:** `{root}`",
        f"- **Pattern:** `{pattern}`",
        f"- **Hits (cap {limit}):** {len(hits)}",
        "",
    ]
    if not hits:
        lines.append("_No matches in `pages/**/*.md`._")
        return "\n".join(lines) + "\n"
    for rel, line_no, preview in hits:
        lines.append(f"- `{rel}`:{line_no} — {preview}")
    lines.append("")
    return "\n".join(lines) + "\n"


__all__ = [
    "MutateGraphAction",
    "ReadGraphTarget",
    "RefactorBlocksAction",
    "RunLinterName",
    "SearchGraphMethod",
    "format_regex_search_markdown",
    "graph_missing_dict",
    "graph_missing_text",
    "graph_path_from_env",
    "parse_json_object",
    "parse_optional_json_query",
    "read_block_ast_markdown",
    "read_subtree_markdown",
    "read_xray_page_markdown",
]
