"""
Robots.txt and Content Signals generator for Bengal SSG.

Generates a robots.txt with Content-Signal directives per
https://contentsignals.org/ and an optional machine-readable
.well-known/content-signals.json manifest.

Content Signals tell automated systems (crawlers, AI models) what
they may do with site content:

- search: indexing for search results
- ai-input: RAG, grounding, real-time AI answers
- ai-train: training or fine-tuning AI models

Signals are resolved from three layers (highest priority first):

1. Page frontmatter ``visibility.ai_train`` / ``visibility.ai_input``
2. Section cascade via ``_index.md``
3. Site-wide ``[content_signals]`` config in bengal.toml

The generator aggregates per-page signals into section-level path
rules and emits a minimal robots.txt with only the necessary
overrides.

Related:
- bengal.core.page.metadata: visibility system with ai_train/ai_input
- bengal.config.defaults: content_signals config section
- bengal.orchestration.postprocess: registration point

"""

from __future__ import annotations

import json
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Any

from bengal.utils.io.atomic_write import AtomicFile
from bengal.utils.observability.logger import get_logger

if TYPE_CHECKING:
    from pathlib import Path

    from bengal.core.output import OutputCollector
    from bengal.protocols import PageLike, SiteLike

logger = get_logger(__name__)

_CONTENT_SIGNALS_SPEC_URL = "https://contentsignals.org/"

_PREAMBLE = """\
# Content Signals — generated by Bengal
# See: {spec_url}
#
# As a condition of accessing this website, you agree to abide by the
# following content signals: (a) If a content-signal = yes, you may
# collect content for the corresponding use. (b) If a content-signal
# = no, you may not collect content for the corresponding use. (c) If
# the website operator does not include a content signal for a
# corresponding use, the website operator neither grants nor restricts
# permission via content signal with respect to the corresponding use.
#
# search: building a search index and providing search results.
# ai-input: inputting content into AI models (e.g. RAG, grounding).
# ai-train: training or fine-tuning AI models.
#
# ANY RESTRICTIONS EXPRESSED VIA CONTENT SIGNALS ARE EXPRESS
# RESERVATIONS OF RIGHTS UNDER ARTICLE 4 OF THE EUROPEAN UNION
# DIRECTIVE 2019/790 ON COPYRIGHT AND RELATED RIGHTS IN THE DIGITAL
# SINGLE MARKET.
"""


@dataclass(frozen=True, slots=True)
class SignalPolicy:
    """Immutable triplet representing a content signal policy."""

    search: bool
    ai_input: bool
    ai_train: bool

    def to_directive(self, path: str | None = None) -> str:
        """Format as a ``Content-Signal`` directive value.

        Args:
            path: Optional path prefix for path-scoped rules.
        """
        parts: list[str] = []
        if path:
            parts.append(path)
        parts.append(f"search={'yes' if self.search else 'no'}")
        parts.append(f"ai-input={'yes' if self.ai_input else 'no'}")
        parts.append(f"ai-train={'yes' if self.ai_train else 'no'}")
        return ", ".join(parts) if not path else f"{path} " + ", ".join(parts[1:])

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, SignalPolicy):
            return NotImplemented
        return (
            self.search == other.search
            and self.ai_input == other.ai_input
            and self.ai_train == other.ai_train
        )

    def __hash__(self) -> int:
        return hash((self.search, self.ai_input, self.ai_train))


class RobotsTxtGenerator:
    """Generate robots.txt with Content-Signal directives.

    Aggregates per-page visibility into section-level path rules and
    writes a minimal robots.txt that declares the site-wide default
    plus any section overrides.

    Also generates ``.well-known/content-signals.json`` — a
    machine-readable policy manifest for AI discovery.
    """

    def __init__(self, site: SiteLike, collector: OutputCollector | None = None) -> None:
        self.site = site
        self._collector = collector
        self._logger = get_logger(__name__)

    def generate(self) -> None:
        """Generate robots.txt and content-signals.json."""
        config = self._get_config()
        if not config.get("enabled", True):
            self._logger.debug("content_signals_disabled")
            return

        default_policy = self._get_default_policy(config)
        section_overrides = self._compute_section_overrides(default_policy)
        user_agents = config.get("user_agents", {"*": None})

        robots_content = self._render_robots_txt(
            default_policy, section_overrides, user_agents, config
        )
        self._write_robots_txt(robots_content)
        self._write_manifest(default_policy, section_overrides, config)

    # ------------------------------------------------------------------
    # Config helpers
    # ------------------------------------------------------------------

    def _get_config(self) -> dict[str, Any]:
        return self.site.config.get("content_signals", {})

    def _get_default_policy(self, config: dict[str, Any]) -> SignalPolicy:
        return SignalPolicy(
            search=config.get("search", True),
            ai_input=config.get("ai_input", True),
            ai_train=config.get("ai_train", False),
        )

    # ------------------------------------------------------------------
    # Section aggregation
    # ------------------------------------------------------------------

    def _compute_section_overrides(self, default_policy: SignalPolicy) -> dict[str, SignalPolicy]:
        """Walk pages, group by section path, detect overrides.

        Returns a mapping of ``/section/path/`` to the effective
        ``SignalPolicy`` for sections that differ from the site default.
        """
        section_pages: dict[str, list[SignalPolicy]] = defaultdict(list)

        for page in self.site.pages:
            if not page.output_path or page.draft:
                continue

            section_path = self._page_section_path(page)
            if not section_path:
                continue

            vis = page.visibility
            policy = SignalPolicy(
                search=vis.get("search", default_policy.search),
                ai_input=vis.get("ai_input", default_policy.ai_input),
                ai_train=vis.get("ai_train", default_policy.ai_train),
            )
            section_pages[section_path].append(policy)

        overrides: dict[str, SignalPolicy] = {}
        for section_path, policies in sorted(section_pages.items()):
            if not policies:
                continue
            representative = policies[0]
            if all(p == representative for p in policies) and representative != default_policy:
                overrides[section_path] = representative

        return overrides

    def _page_section_path(self, page: PageLike) -> str:
        """Derive section URL path from a page's internal path."""
        path = getattr(page, "_path", None) or ""
        if not path or path == "/":
            return ""
        parts = [p for p in path.strip("/").split("/") if p]
        if len(parts) <= 1:
            return ""
        return "/" + parts[0] + "/"

    # ------------------------------------------------------------------
    # Rendering
    # ------------------------------------------------------------------

    def _render_robots_txt(
        self,
        default_policy: SignalPolicy,
        section_overrides: dict[str, SignalPolicy],
        user_agents: dict[str, Any],
        config: dict[str, Any],
    ) -> str:
        lines: list[str] = []
        lines.append(_PREAMBLE.format(spec_url=_CONTENT_SIGNALS_SPEC_URL).rstrip())
        lines.append("")

        for agent, agent_overrides in user_agents.items():
            effective = default_policy
            if isinstance(agent_overrides, dict):
                effective = SignalPolicy(
                    search=agent_overrides.get("search", default_policy.search),
                    ai_input=agent_overrides.get("ai_input", default_policy.ai_input),
                    ai_train=agent_overrides.get("ai_train", default_policy.ai_train),
                )

            lines.append(f"User-Agent: {agent}")
            lines.append(f"Content-Signal: {effective.to_directive()}")
            lines.append("Allow: /")
            lines.append("")

        if section_overrides:
            section_agents = list(user_agents) or ["*"]
            for section_path, policy in sorted(section_overrides.items()):
                all_denied = not policy.search and not policy.ai_input and not policy.ai_train
                for agent in section_agents:
                    lines.append(f"User-Agent: {agent}")
                    lines.append(f"Content-Signal: {policy.to_directive(section_path)}")
                    if all_denied:
                        lines.append(f"Disallow: {section_path}")
                    else:
                        lines.append(f"Allow: {section_path}")
                    lines.append("")

        if config.get("include_sitemap", True):
            baseurl = (self.site.baseurl or "").rstrip("/")
            if baseurl:
                lines.append(f"Sitemap: {baseurl}/sitemap.xml")
                lines.append("")

        return "\n".join(lines)

    # ------------------------------------------------------------------
    # Writers
    # ------------------------------------------------------------------

    def _write_robots_txt(self, content: str) -> None:
        output_path: Path = self.site.output_dir / "robots.txt"
        output_path.parent.mkdir(parents=True, exist_ok=True)
        with AtomicFile(output_path, "w", encoding="utf-8") as f:
            f.write(content)

        if self._collector:
            self._collector.record(output_path)

        self._logger.info("robots_txt_generated", path=str(output_path))

    def _write_manifest(
        self,
        default_policy: SignalPolicy,
        section_overrides: dict[str, SignalPolicy],
        config: dict[str, Any],
    ) -> None:
        well_known_dir: Path = self.site.output_dir / ".well-known"
        well_known_dir.mkdir(parents=True, exist_ok=True)
        manifest_path = well_known_dir / "content-signals.json"

        overrides_json: dict[str, dict[str, bool]] = {}
        for path, policy in sorted(section_overrides.items()):
            entry: dict[str, bool] = {}
            if policy.search != default_policy.search:
                entry["search"] = policy.search
            if policy.ai_input != default_policy.ai_input:
                entry["ai_input"] = policy.ai_input
            if policy.ai_train != default_policy.ai_train:
                entry["ai_train"] = policy.ai_train
            if entry:
                overrides_json[path] = entry

        build_time = getattr(self.site, "build_time", None)
        last_build = (
            build_time.isoformat()
            if isinstance(build_time, datetime)
            else datetime.now().isoformat()
        )

        manifest: dict[str, Any] = {
            "version": "1.0",
            "generator": "bengal",
            "spec": _CONTENT_SIGNALS_SPEC_URL,
            "default_policy": {
                "search": default_policy.search,
                "ai_input": default_policy.ai_input,
                "ai_train": default_policy.ai_train,
            },
        }
        if overrides_json:
            manifest["section_overrides"] = overrides_json

        output_formats_config = self.site.config.get("output_formats", {})
        formats: dict[str, str] = {"html": "/{path}/"}
        per_page = output_formats_config.get("per_page", [])
        if "json" in per_page:
            formats["json"] = "/{path}/index.json"
        if "llm_txt" in per_page:
            formats["txt"] = "/{path}/index.txt"
        site_wide = output_formats_config.get("site_wide", [])
        if "index_json" in site_wide:
            formats["search_index"] = "/index.json"
        if "llm_full" in site_wide:
            formats["llm_corpus"] = "/llm-full.txt"
        if "llms_txt" in site_wide:
            formats["llms_overview"] = "/llms.txt"
        if "changelog" in site_wide:
            formats["changelog"] = "/changelog.json"
        if "agent_manifest" in site_wide:
            formats["agent_manifest"] = "/agent.json"
        if config.get("include_sitemap", True):
            formats["sitemap"] = "/sitemap.xml"
        if self.site.config.get("generate_rss", True):
            formats["rss"] = "/rss.xml"
        manifest["formats"] = formats

        page_count = sum(1 for p in self.site.pages if p.output_path and not p.draft)
        manifest["page_count"] = page_count
        if not getattr(self.site, "dev_mode", False):
            manifest["last_build"] = last_build

        with AtomicFile(manifest_path, "w", encoding="utf-8") as f:
            json.dump(manifest, f, indent=2, ensure_ascii=False)

        if self._collector:
            self._collector.record(manifest_path)

        self._logger.info("content_signals_manifest_generated", path=str(manifest_path))
