"""Content adapters — convert vault artifacts into VideoSpecs.

Each adapter is a pure function that takes a target identifier + options
and returns a `VideoSpec` ready for the engine to render.

User-facing adapters (wiki/kb/project/journal/url) use only the safe
action subset — navigate, scroll, caption, wait_for_audio. Zero clicks,
zero selectors that could drift with UI changes.

Demo adapter (load_spec_yaml in specs.py) supports the full action set
including click/type for tool-flow demos — Phase 2.

Flow per adapter:
  1. Fetch artifact content (via vault_reader or HTTP scraper)
  2. Call LLM with scene-prompt.yaml template → YAML scene outline
  3. Parse scene outline → Scene list
  4. Attach Action sequences (navigate + scroll + caption + wait_for_audio)
  5. Return VideoSpec
"""

from __future__ import annotations

import logging
import re
from pathlib import Path
from typing import Any

import yaml

from .specs import Action, Scene, VideoSpec

log = logging.getLogger(__name__)


# ── Dispatch ─────────────────────────────────────────────────────

async def adapter_for_target(
    target: str,
    *,
    max_seconds: int,
    style: str,
    voice: str,
    llm: Any,
    vault_reader: Any,
    skill_loader: Any,
) -> VideoSpec:
    """Resolve a `"type:value"` target string to the right adapter."""

    if ":" not in target:
        raise ValueError(
            f"Invalid target {target!r}; expected 'wiki:slug' / 'kb:slug' / "
            "'project:slug' / 'journal:YYYY-MM-DD' / 'url:https://...'"
        )
    kind, _, value = target.partition(":")
    common = dict(
        max_seconds=max_seconds, style=style, voice=voice,
        llm=llm, skill_loader=skill_loader,
    )
    if kind == "wiki":
        return await adapter_wiki(value, vault_reader=vault_reader, **common)
    if kind == "kb":
        return await adapter_kb(value, vault_reader=vault_reader, **common)
    if kind == "project":
        return await adapter_project(value, vault_reader=vault_reader, **common)
    if kind == "journal":
        return await adapter_journal(value, vault_reader=vault_reader, **common)
    if kind == "url":
        return await adapter_url(value, **common)
    raise ValueError(f"Unknown target kind {kind!r}; supported: wiki/kb/project/journal/url")


# ── Wiki adapter ─────────────────────────────────────────────────

async def adapter_wiki(
    slug: str,
    *,
    max_seconds: int,
    style: str,
    voice: str,
    llm: Any,
    vault_reader: Any,
    skill_loader: Any,
) -> VideoSpec:
    """Build a VideoSpec for a single wiki article."""

    if not vault_reader:
        raise ValueError("adapter_wiki requires a vault_reader")

    title, content = _read_wiki_article(vault_reader, slug)
    if not content.strip():
        raise ValueError(f"Wiki article {slug!r} empty or missing")

    scenes = await _llm_scene_outline(
        kind="wiki",
        llm=llm,
        skill_loader=skill_loader,
        style=style,
        max_seconds=max_seconds,
        context={"title": title, "content": _clip(content, 6000)},
    )
    _attach_wiki_actions(scenes, slug=slug)
    return VideoSpec(
        name=_safe_slug(slug),
        target=f"wiki:{slug}",
        style=style,
        voice=voice,
        duration_seconds=max_seconds,
        scenes=scenes,
    )


def _read_wiki_article(vault_reader: Any, slug: str) -> tuple[str, str]:
    """Find a wiki article by slug. Returns (title, body)."""

    # Try common KB locations: knowledge/*/wiki/entities|concepts|sources|*.md
    # Reuse vault_reader's search helper if present.
    candidate: Path | None = None
    knowledge = _vault_root(vault_reader) / "agntspace-knowledge"
    if not knowledge.exists():
        raise ValueError(f"No knowledge dir found at {knowledge}")

    for md in knowledge.glob("*/wiki/**/*.md"):
        if md.name.startswith((".", "_")):
            continue
        # Match by filename stem OR by frontmatter slug
        if _slug_match(md.stem, slug):
            candidate = md
            break
        try:
            text = md.read_text(encoding="utf-8", errors="replace")
            if f"slug: {slug}" in text or f"slug: \"{slug}\"" in text:
                candidate = md
                break
        except OSError:
            continue

    if not candidate:
        raise ValueError(f"Wiki article {slug!r} not found")

    raw = candidate.read_text(encoding="utf-8", errors="replace")
    title, body = _split_frontmatter(raw)
    if not title:
        title = candidate.stem
    return title, body


def _attach_wiki_actions(scenes: list[Scene], *, slug: str) -> None:
    """Fill in Action sequences for wiki scenes.

    First scene navigates to /wiki/{slug}; subsequent scenes scroll within
    the same page. Each scene gets a caption + wait_for_audio.
    """

    for i, scene in enumerate(scenes):
        acts: list[Action] = []
        if i == 0:
            acts.append(Action(type="navigate", target=f"/wiki/{slug}"))
        else:
            # Spread scroll positions across the article — 300px per scene
            acts.append(Action(type="scroll", target=300 * i, dwell_seconds=1.5))
        acts.append(Action(type="caption", text=scene.narration, dwell_seconds=0.0))
        acts.append(Action(type="wait_for_audio"))
        scene.actions = acts


# ── KB adapter ───────────────────────────────────────────────────

async def adapter_kb(
    slug: str,
    *,
    max_seconds: int,
    style: str,
    voice: str,
    llm: Any,
    vault_reader: Any,
    skill_loader: Any,
) -> VideoSpec:
    """Build a VideoSpec walking a knowledge base's featured articles."""

    if not vault_reader:
        raise ValueError("adapter_kb requires a vault_reader")

    kb_dir = _vault_root(vault_reader) / "agntspace-knowledge" / slug
    if not kb_dir.exists():
        raise ValueError(f"KB {slug!r} not found at {kb_dir}")

    # Harvest featured articles — title + 1-line summary from frontmatter.description or first paragraph
    wiki_root = kb_dir / "wiki"
    article_lines: list[str] = []
    for md in sorted(wiki_root.rglob("*.md"))[:12]:
        if md.name.startswith((".", "_")):
            continue
        title, body = _split_frontmatter(md.read_text(encoding="utf-8", errors="replace"))
        summary = _first_nontrivial_line(body)
        article_lines.append(f"- **{title or md.stem}**: {summary}")

    scenes = await _llm_scene_outline(
        kind="kb",
        llm=llm,
        skill_loader=skill_loader,
        style=style,
        max_seconds=max_seconds,
        context={
            "title": slug,
            "description": f"Knowledge base: {slug}",
            "article_count": str(len(article_lines)),
            "content": "\n".join(article_lines[:20]),
        },
    )
    _attach_kb_actions(scenes, slug=slug)
    return VideoSpec(
        name=f"kb-{_safe_slug(slug)}",
        target=f"kb:{slug}",
        style=style,
        voice=voice,
        duration_seconds=max_seconds,
        scenes=scenes,
    )


def _attach_kb_actions(scenes: list[Scene], *, slug: str) -> None:
    """KB flow: open /wiki (KB landing), caption + wait per scene."""

    for i, scene in enumerate(scenes):
        acts: list[Action] = []
        if i == 0:
            acts.append(Action(type="navigate", target=f"/wiki?kb={slug}"))
        else:
            acts.append(Action(type="scroll", target=200 * i, dwell_seconds=1.0))
        acts.append(Action(type="caption", text=scene.narration, dwell_seconds=0.0))
        acts.append(Action(type="wait_for_audio"))
        scene.actions = acts


# ── Project adapter ─────────────────────────────────────────────

async def adapter_project(
    slug: str,
    *,
    max_seconds: int,
    style: str,
    voice: str,
    llm: Any,
    vault_reader: Any,
    skill_loader: Any,
) -> VideoSpec:
    """Build a VideoSpec for a project (page + goals + recent activity)."""

    if not vault_reader:
        raise ValueError("adapter_project requires a vault_reader")

    project_md = _vault_root(vault_reader) / "agntspace-projects" / slug / "PROJECT.md"
    if not project_md.exists():
        raise ValueError(f"Project {slug!r} not found at {project_md}")

    title, body = _split_frontmatter(project_md.read_text(encoding="utf-8", errors="replace"))
    fm_status = _first_frontmatter_value(project_md.read_text(encoding="utf-8", errors="replace"), "status")

    # Pull recent progress log entries
    recent = []
    for line in body.splitlines():
        if line.startswith("- 20"):  # matches "- 2026-..." log entries
            recent.append(line)
            if len(recent) >= 10:
                break

    scenes = await _llm_scene_outline(
        kind="project",
        llm=llm,
        skill_loader=skill_loader,
        style=style,
        max_seconds=max_seconds,
        context={
            "title": title or slug,
            "status": fm_status or "active",
            "goals": "",
            "recent_activity": "\n".join(recent[:10]),
            "content": _clip(body, 4000),
        },
    )
    _attach_project_actions(scenes, slug=slug)
    return VideoSpec(
        name=f"project-{_safe_slug(slug)}",
        target=f"project:{slug}",
        style=style,
        voice=voice,
        duration_seconds=max_seconds,
        scenes=scenes,
    )


def _attach_project_actions(scenes: list[Scene], *, slug: str) -> None:
    for i, scene in enumerate(scenes):
        acts: list[Action] = []
        if i == 0:
            acts.append(Action(type="navigate", target=f"/projects/{slug}"))
        else:
            acts.append(Action(type="scroll", target=250 * i, dwell_seconds=1.0))
        acts.append(Action(type="caption", text=scene.narration, dwell_seconds=0.0))
        acts.append(Action(type="wait_for_audio"))
        scene.actions = acts


# ── Journal adapter ─────────────────────────────────────────────

async def adapter_journal(
    date: str,
    *,
    max_seconds: int,
    style: str,
    voice: str,
    llm: Any,
    vault_reader: Any,
    skill_loader: Any,
) -> VideoSpec:
    """Build a VideoSpec for a single day's user journal."""

    if not vault_reader:
        raise ValueError("adapter_journal requires a vault_reader")

    journal_md = _vault_root(vault_reader) / "agntspace-journal" / "user" / f"user-journal_{date}.md"
    if not journal_md.exists():
        raise ValueError(f"Journal {date!r} not found at {journal_md}")

    _, body = _split_frontmatter(journal_md.read_text(encoding="utf-8", errors="replace"))
    scenes = await _llm_scene_outline(
        kind="journal",
        llm=llm,
        skill_loader=skill_loader,
        style=style,
        max_seconds=max_seconds,
        context={
            "date": date,
            "content": _clip(body, 4000),
        },
    )
    _attach_journal_actions(scenes, date=date)
    return VideoSpec(
        name=f"journal-{date}",
        target=f"journal:{date}",
        style=style,
        voice=voice,
        duration_seconds=max_seconds,
        scenes=scenes,
    )


def _attach_journal_actions(scenes: list[Scene], *, date: str) -> None:
    for i, scene in enumerate(scenes):
        acts: list[Action] = []
        if i == 0:
            acts.append(Action(type="navigate", target=f"/journal?date={date}"))
        else:
            acts.append(Action(type="scroll", target=200 * i, dwell_seconds=1.0))
        acts.append(Action(type="caption", text=scene.narration, dwell_seconds=0.0))
        acts.append(Action(type="wait_for_audio"))
        scene.actions = acts


# ── URL adapter ─────────────────────────────────────────────────

async def adapter_url(
    url: str,
    *,
    max_seconds: int,
    style: str,
    voice: str,
    llm: Any,
    skill_loader: Any,
) -> VideoSpec:
    """Build a VideoSpec for an arbitrary URL.

    Scrape the page text (via existing browser helper if available),
    generate scene outline, open the URL directly in the recorder.
    """

    # Deferred import — optional; fall back to "title only" if browser module absent
    title = ""
    page_text = ""
    try:
        from agntspace.integrations.browser import scrape_page_detailed
        page_text, err = scrape_page_detailed(url)
        if err:
            log.warning("url adapter scrape failed: %s", err)
    except Exception as exc:  # noqa: BLE001
        log.debug("browser scraper unavailable: %s", exc)

    if page_text:
        # Try to pull title from first markdown-style heading if present
        m = re.search(r"^\s*#\s+(.+)$", page_text, re.MULTILINE)
        if m:
            title = m.group(1).strip()

    if not title:
        title = url

    scenes = await _llm_scene_outline(
        kind="url",
        llm=llm,
        skill_loader=skill_loader,
        style=style,
        max_seconds=max_seconds,
        context={
            "url": url,
            "title": title,
            "content": _clip(page_text or f"(No scraped content; page at {url})", 4000),
        },
    )
    _attach_url_actions(scenes, url=url)
    return VideoSpec(
        name=_safe_slug(url.replace("https://", "").replace("http://", "").split("/")[0]),
        target=f"url:{url}",
        style=style,
        voice=voice,
        duration_seconds=max_seconds,
        scenes=scenes,
    )


def _attach_url_actions(scenes: list[Scene], *, url: str) -> None:
    for i, scene in enumerate(scenes):
        acts: list[Action] = []
        if i == 0:
            acts.append(Action(type="navigate", target=url))
        else:
            acts.append(Action(type="scroll", target=400 * i, dwell_seconds=1.0))
        acts.append(Action(type="caption", text=scene.narration, dwell_seconds=0.0))
        acts.append(Action(type="wait_for_audio"))
        scene.actions = acts


# ── LLM scene-outline generator ──────────────────────────────────

async def _llm_scene_outline(
    *,
    kind: str,
    llm: Any,
    skill_loader: Any,
    style: str,
    max_seconds: int,
    context: dict[str, str],
) -> list[Scene]:
    """Generate a scene outline by calling the LLM with the prompt template."""

    if llm is None:
        raise ValueError("LLM is required for scene outline generation")

    from agntspace.llm.base import Message

    prompt_cfg = _load_prompt_config(skill_loader)
    style_cfg = _load_style_config(skill_loader)

    prompts = prompt_cfg.get("prompts", {}) or {}
    template = prompts.get(kind) or prompts.get("wiki")
    if not template:
        raise ValueError(f"No scene-prompt template for kind={kind!r}")

    limits = style_cfg.get("limits", {}) or {}
    max_scenes = int(limits.get("max_scenes", 12))
    per_target = (style_cfg.get("defaults_per_target", {}) or {}).get(kind, {})
    max_scenes = min(max_scenes, int(per_target.get("max_scenes", max_scenes)))

    preset = (style_cfg.get("presets", {}) or {}).get(style, {})
    spw = float(preset.get("seconds_per_word_min", 0.30))
    tone_hint = preset.get("tone_hint", "conversational, friendly, knowledgeable")
    target_words = int(max(20, (max_seconds / max(spw, 0.1)) * 0.85))

    system = template["system"].format(
        tone_hint=tone_hint,
        target_word_count=target_words,
        max_scenes=max_scenes,
    )
    user = template["user"].format(**{**context, "max_scenes": max_scenes})

    response = await llm.complete(
        messages=[Message(role="user", content=user)],
        system=system,
        max_tokens=1800,
        temperature=0.4,
    )
    raw = response.content if hasattr(response, "content") else str(response)

    scenes = _parse_scenes_yaml(raw, max_scenes=max_scenes, prompt_cfg=prompt_cfg)
    if not scenes:
        # Include a bounded preview of the raw LLM response in the error so the
        # user can see WHY parsing failed (refusal, wrong format, markdown prose).
        preview = (raw or "").strip().replace("\n", " ")[:400]
        log.warning("scene outline empty. kind=%s raw[:400]=%r", kind, preview)
        raise ValueError(
            f"LLM returned no usable scenes for {kind}. "
            f"Raw preview: {preview!r}"
        )
    return scenes


def _parse_scenes_yaml(raw: str, *, max_scenes: int, prompt_cfg: dict[str, Any]) -> list[Scene]:
    """Parse LLM YAML output → list[Scene]. Robust to code-fence wrapping."""

    validation = prompt_cfg.get("validation", {}) or {}
    reject_chars = validation.get("reject_special_chars_in_narration", []) or []

    text = raw.strip()
    # Strip markdown code fences if present
    fence = re.search(r"```(?:ya?ml)?\n?(.*?)```", text, re.DOTALL)
    if fence:
        text = fence.group(1).strip()

    try:
        data = yaml.safe_load(text)
    except yaml.YAMLError as exc:
        log.warning("scene YAML parse failed: %s", exc)
        return []

    if isinstance(data, dict) and "scenes" in data:
        data = data["scenes"]
    if not isinstance(data, list):
        log.warning("scene YAML root is not a list: %r", type(data))
        return []

    scenes: list[Scene] = []
    seen_ids: set[str] = set()
    for raw_scene in data:
        if not isinstance(raw_scene, dict):
            continue
        sid = str(raw_scene.get("id", "")).strip() or f"scene_{len(scenes)+1}"
        narration = str(raw_scene.get("narration", "")).strip()
        focus = str(raw_scene.get("focus", "")).strip()

        if not narration and validation.get("drop_scene_if_narration_empty", True):
            continue
        n_words = len(narration.split())
        limit_words = int(validation.get("drop_scene_if_narration_over_n_words", 50))
        if n_words > limit_words:
            log.debug("dropping verbose scene %s (%d words > %d)", sid, n_words, limit_words)
            continue
        if sid in seen_ids and validation.get("drop_scene_if_id_collides_with_previous", True):
            continue
        seen_ids.add(sid)

        # Auto-strip reject chars (warn + replace with sane substitute)
        for bad in reject_chars:
            if bad and bad in narration:
                if bad in ("—", "–"):
                    narration = narration.replace(bad, ", ")
                elif bad == "…":
                    narration = narration.replace(bad, ".")
                else:
                    narration = narration.replace(bad, " ")

        scenes.append(Scene(id=sid, narration=narration, focus=focus))

        if validation.get("enforce_max_scenes", True) and len(scenes) >= max_scenes:
            break

    return scenes


def _load_prompt_config(skill_loader: Any) -> dict[str, Any]:
    if skill_loader is None:
        return {}
    try:
        data = skill_loader.load_skill_config_file("video-producer", "scene-prompt.yaml")
        return data if isinstance(data, dict) else {}
    except Exception as exc:  # noqa: BLE001
        log.debug("scene-prompt.yaml load failed: %s", exc)
        return {}


def _load_style_config(skill_loader: Any) -> dict[str, Any]:
    if skill_loader is None:
        return {}
    try:
        data = skill_loader.load_skill_config_file("video-producer", "styles.yaml")
        return data if isinstance(data, dict) else {}
    except Exception as exc:  # noqa: BLE001
        log.debug("styles.yaml load failed: %s", exc)
        return {}


# ── Shared helpers ──────────────────────────────────────────────

def _vault_root(vault_reader: Any) -> Path:
    """Resolve the vault root from a VaultReader.

    VaultReader.vault_dir points at the internals dir (`<root>/agntspace/`).
    The user-facing `agntspace-*` sibling dirs (knowledge, projects, etc.)
    live at the root. Prefer the explicit VaultPaths.root_dir when available;
    fall back to `.vault_dir.parent`.
    """

    paths = getattr(vault_reader, "paths", None)
    if paths is not None and getattr(paths, "root_dir", None):
        return Path(paths.root_dir)
    vault_dir = getattr(vault_reader, "vault_dir", None)
    if vault_dir is None:
        raise ValueError("vault_reader has no .vault_dir or .paths.root_dir")
    return Path(vault_dir).parent


def _split_frontmatter(raw: str) -> tuple[str, str]:
    """Return (title, body). Parses `---` frontmatter block if present."""

    if not raw.startswith("---"):
        return "", raw
    m = re.match(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", raw, re.DOTALL)
    if not m:
        return "", raw
    fm_block, body = m.group(1), m.group(2)
    title = ""
    for line in fm_block.splitlines():
        if line.startswith("title:"):
            title = line.split(":", 1)[1].strip().strip('"\'')
            break
    return title, body


def _first_frontmatter_value(raw: str, key: str) -> str:
    if not raw.startswith("---"):
        return ""
    m = re.match(r"^---\s*\n(.*?)\n---", raw, re.DOTALL)
    if not m:
        return ""
    for line in m.group(1).splitlines():
        if line.startswith(f"{key}:"):
            return line.split(":", 1)[1].strip().strip('"\'')
    return ""


def _first_nontrivial_line(body: str) -> str:
    """First non-empty, non-heading paragraph line."""

    for line in body.splitlines():
        stripped = line.strip()
        if not stripped or stripped.startswith(("#", "|", "---", "```")):
            continue
        return _clip(stripped, 160)
    return ""


def _slug_match(a: str, b: str) -> bool:
    """Match two slugs with lenient normalization."""

    return _normalize_slug(a) == _normalize_slug(b)


def _normalize_slug(s: str) -> str:
    return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")


def _safe_slug(s: str) -> str:
    return _normalize_slug(s)[:80] or "video"


def _clip(text: str, n: int) -> str:
    if len(text) <= n:
        return text
    return text[:n] + "\n... [truncated]"
