"""Reflection: synthesize journal entries into long-term memory.

ARCHITECTURE INVARIANT: Reflection files are ALWAYS produced when requested.
If journal data is missing, the reflection notes the gap. LLM failures are
retried indefinitely — the system never gives up. Model attribution is
included in every generated file.
"""

from __future__ import annotations

import asyncio
import logging
from datetime import UTC, datetime

from agntspace.vault.reader import VaultReader
from agntspace.vault.writer import VaultWriter

log = logging.getLogger(__name__)

# Retry delays — exponential backoff capped at 5 minutes
_RETRY_DELAYS = [10, 30, 60, 120, 300]


def _retry_delay(attempt: int) -> int:
    idx = min(attempt - 1, len(_RETRY_DELAYS) - 1)
    return _RETRY_DELAYS[idx]


def _get_model_name(agent: object | None) -> str:
    """Extract the current model name from the agent's LLM provider."""
    try:
        if agent and hasattr(agent, "_llm") and hasattr(agent._llm, "_model"):
            return agent._llm._model
    except Exception:
        pass
    return "unknown"


class ReflectionService:
    """Runs daily and weekly reflection cycles."""

    def __init__(
        self,
        vault_reader: VaultReader,
        vault_writer: VaultWriter,
        llm: object | None = None,
        memory_engine: object | None = None,
        skill_loader: object | None = None,
        model_router: object | None = None,
        orchestrator: object | None = None,
    ) -> None:
        self._reader = vault_reader
        self._writer = vault_writer
        self._llm = llm  # kept for backwards compat, no longer used
        self._memory_engine = memory_engine
        self._skill_loader = skill_loader
        self._orchestrator = orchestrator
        self._model_router = model_router
        self._agent: object | None = None  # injected post-hoc from main.py
        self._contract: object | None = None  # injected post-hoc from main.py
        self._activity: object | None = None  # injected post-hoc from main.py
        self._decisions: object | None = None  # injected post-hoc from main.py

    # ── LLM call with infinite retry ───────────────────────────────

    async def _call_agent(
        self, task: str, context: str, label: str,
        model_tier: str = "capable", agent_key: str = "philosopher",
    ) -> str:
        """Call the agent, retrying indefinitely until content is produced.

        Post-processes output to ensure clean markdown.
        Runs fabrication detection to strip invented proper nouns (Rule #16).

        Args:
            model_tier: LLM tier to use. Defaults to "capable" — reflections
                        need pattern recognition and nuanced judgment.
            agent_key: Agent key for activity tracking in constellation view.
        """
        import time as _time

        from agntspace.automation.daily_cycle import sanitize_llm_output
        from agntspace.automation.fabrication_detector import check_fabrication

        attempt = 0
        started = datetime.now(UTC).isoformat()
        t0 = _time.monotonic()
        while True:
            attempt += 1

            if not self._agent:
                delay = _retry_delay(attempt)
                log.warning("[%s] Agent not available — waiting %ds before retry (attempt %d)", label, delay, attempt)
                await asyncio.sleep(delay)
                continue

            try:
                # Track activity so constellation shows this agent working
                if self._orchestrator and hasattr(self._orchestrator, "_active_tasks"):
                    self._orchestrator._active_tasks[agent_key] = label
                result = await self._agent.process_task(task=task, context=context, model_tier=model_tier)
                if result and result.strip():
                    clean = sanitize_llm_output(result)
                    if clean and clean.strip():
                        # ── Fabrication detection (Rule #16) ──────
                        try:
                            fab_report = check_fabrication(clean, context, label=label, vault_reader=self._reader)
                            if fab_report.was_modified:
                                clean = fab_report.clean_output
                                activity = getattr(self, "_activity", None)
                                if activity:
                                    try:
                                        activity.log(
                                            category="system",
                                            action="fabrication_stripped",
                                            summary=f"Fabricated names removed from {label}: {', '.join(fab_report.fabricated_names)}",
                                            details={
                                                "label": label,
                                                "fabricated_names": fab_report.fabricated_names,
                                                "flagged_lines": fab_report.flagged_lines[:10],
                                            },
                                            importance="high",
                                        )
                                    except Exception:
                                        pass
                        except Exception as fab_exc:
                            log.debug("[%s] Fabrication check failed: %s", label, fab_exc)

                        if attempt > 1:
                            log.warning("[%s] Succeeded on attempt %d", label, attempt)
                        # Log to dispatch timeline
                        if self._orchestrator and hasattr(self._orchestrator, "log_background_task"):
                            duration_ms = int((_time.monotonic() - t0) * 1000)
                            try:
                                await self._orchestrator.log_background_task(
                                    agent_key=agent_key, task=label,
                                    content=clean[:500], started_at=started,
                                    duration_ms=duration_ms,
                                )
                            except Exception:
                                pass
                        return clean
                log.warning("[%s] Agent returned empty response (attempt %d)", label, attempt)
            except Exception as exc:
                log.warning("[%s] LLM call failed (attempt %d): %s", label, attempt, exc)
            finally:
                if self._orchestrator and hasattr(self._orchestrator, "_active_tasks"):
                    self._orchestrator._active_tasks.pop(agent_key, None)

            delay = _retry_delay(attempt)
            log.warning("[%s] Retrying in %ds...", label, delay)
            await asyncio.sleep(delay)

    # ── Frontmatter helpers ────────────────────────────────────────

    def _build_frontmatter(
        self,
        title: str,
        date: str,
        file_type: str,
        agent_name: str,
        description: str,
        extra: dict | None = None,
    ) -> str:
        """Build YAML frontmatter with model attribution and generation timestamp.

        Stamps the enclosing ``correlation_id`` when one is active, so
        approve/reject endpoints can feed user decisions back into the
        evolution layer's feedback signal (matching run → posterior update).
        """
        from agntspace.activity.decisions import current_correlation_id
        corr_id = current_correlation_id() or ""

        model = _get_model_name(self._agent)
        now = datetime.now(UTC)
        generated_at = now.strftime("%H:%M UTC")
        lines = [
            "---",
            f'title: "{title}"',
            f'date: "{date}"',
            f'generated_at: "{generated_at}"',
            f"type: {file_type}",
            "author: agent",
            f"agent: {agent_name}",
            f'model: "{model}"',
            f'description: "{description}"',
        ]
        if corr_id:
            lines.append(f'correlation_id: "{corr_id}"')
        if extra:
            for k, v in extra.items():
                if isinstance(v, bool):
                    lines.append(f"{k}: {'true' if v else 'false'}")
                elif isinstance(v, str):
                    lines.append(f"{k}: {v}")
                else:
                    lines.append(f"{k}: {v}")
        lines.append("---")
        return "\n".join(lines)

    def _is_fallback_file(self, path: str) -> bool:
        """Check if a file is a fallback placeholder (should be regenerated)."""
        try:
            if self._reader.exists(path):
                doc = self._reader.read(path)
                return doc.metadata.get("generation_failed", False) is True
        except Exception:
            pass
        return False

    # ── Daily reflection ───────────────────────────────────────────

    def _check_contract(self, action: str) -> bool:
        """Check contract permission. Returns True if allowed."""
        if not self._contract or not hasattr(self._contract, "check"):
            return True
        result = self._contract.check(action)
        if not result.allowed:
            log.warning("Contract blocked '%s': %s", action, result.reason)
            return False
        return True

    async def run_daily_reflection(self, date: str | None = None) -> dict:
        """Run daily reflection. Contract-gated: `generate_reflection`.

        If journal is missing, notes the gap and works with whatever is available.
        Retries indefinitely until LLM content is produced.
        """
        if not date:
            from agntspace.infra.timezone import local_today
            date = local_today()

        if not self._check_contract("generate_reflection"):
            return {"date": date, "status": "blocked_by_contract"}

        _AGNT = "journal/agnt"
        _USER = "journal/user"

        def _resolve_agnt(filename: str) -> str:
            return f"{_AGNT}/{filename}"

        def _resolve_user(date_str: str) -> str:
            return f"{_USER}/user-journal_{date_str}.md"

        reflection_path = _resolve_agnt(f"daily-reflection_{date}.md")
        label = f"Daily reflection ({date})"

        # Skip if already generated successfully (not a fallback)
        if self._reader.exists(reflection_path) and not self._is_fallback_file(reflection_path):
            log.warning("[%s] Already exists, skipping", label)
            doc = self._reader.read(reflection_path)
            return {
                "date": date,
                "reflection": doc.content,
                "status": doc.metadata.get("status", "pending"),
                "path": reflection_path,
            }

        # Build context — gather whatever is available
        context_parts: list[str] = []
        has_journal = False

        journal_path = _resolve_user(date)
        if self._reader.exists(journal_path):
            try:
                journal = self._reader.read(journal_path)
                if len(journal.content.strip()) > 10:
                    context_parts.append(f"# User Journal\n\n{journal.content}")
                    has_journal = True
                else:
                    context_parts.append("# User Journal\n\n(Journal exists but has minimal content.)")
            except Exception as exc:
                context_parts.append(f"# User Journal\n\n(Failed to read journal: {exc})")
                log.warning("[%s] Failed to read journal: %s", label, exc)
        else:
            context_parts.append("# User Journal\n\n(No journal entry for this date.)")

        # Activity summary from logger — raw event stream (richer than observations file)
        activity_logger = getattr(self, "_activity", None)
        if activity_logger:
            try:
                activity_summary = activity_logger.get_daily_summary(date)
                if activity_summary:
                    context_parts.append(f"# System Activity\n\n{activity_summary[:2000]}")
            except Exception as exc:
                log.debug("[%s] Failed to read activity summary: %s", label, exc)

        # Agent observations
        agent_obs_path = _resolve_agnt(f"agent-observations_{date}.md")
        if self._reader.exists(agent_obs_path):
            try:
                agent_obs = self._reader.read(agent_obs_path)
                context_parts.append(f"# Agent Observations (supplementary)\n\n{agent_obs.content}")
            except Exception:
                pass

        # EOD report
        eod_path = _resolve_agnt(f"eod-report_{date}.md")
        if self._reader.exists(eod_path):
            try:
                eod_doc = self._reader.read(eod_path)
                if not eod_doc.metadata.get("generation_failed", False):
                    context_parts.append(f"# EOD Report\n\n{eod_doc.content}")
            except Exception:
                pass

        # Morning briefing
        briefing_path = _resolve_agnt(f"morning-briefing_{date}.md")
        if self._reader.exists(briefing_path):
            try:
                briefing_doc = self._reader.read(briefing_path)
                if not briefing_doc.metadata.get("generation_failed", False):
                    context_parts.append(f"# Morning Briefing\n\n{briefing_doc.content}")
            except Exception:
                pass

        context = "\n\n---\n\n".join(context_parts)

        availability_note = ""
        if not has_journal:
            availability_note = (
                "\nNOTE: No user journal entry was found for this date. "
                "Base the reflection on agent observations, EOD report, and briefing if available. "
                "If nothing is available, produce a minimal reflection noting the gap.\n"
            )

        from agntspace.journal.format_loader import build_task_instruction

        _FALLBACK_DAILY_REFL = (
            f"Produce a daily reflection for {date} using EXACTLY this markdown format:\n\n"
            "## Summary\n2-3 sentences on what happened today — the big picture.\n\n"
            "## Key Decisions\n- **Decision** — reasoning behind it\n\n"
            "## Learnings\n- Thing worth remembering long-term\n\n"
            "## Suggested Memory Updates\n- Add: specific fact to add to long-term memory\n"
            "- Update: correction to existing memory\n\n"
            "---\n" + availability_note +
            "RULES: Output ONLY valid markdown. Use ## headers exactly as shown. "
            "Use bullet lists (- item) for lists. Focus on what's worth remembering "
            "long-term, not transient details. Never output JSON."
        )
        task_instruction = build_task_instruction(
            self._skill_loader, "daily-reflection",
            preamble=f"Produce a daily reflection for {date} using EXACTLY this markdown format:",
            suffix=availability_note,
            fallback=_FALLBACK_DAILY_REFL,
        )

        from agntspace.activity.scoped import scoped_operation

        async with scoped_operation(
            writer=self._writer,
            contract=self._contract,
            decisions=self._decisions,
            activity=self._activity,
            actor="philosopher",
            action_type="daily_reflection",
            action_target=reflection_path,
            reason="daily_reflection",
            scope_prefix="daily-reflection",
            rationale=f"Daily reflection for {date}",
            caller="daily_cycle",
            skill_context=["daily-reflection"],
        ) as _scope:
            reflection_content = await self._call_agent(task_instruction, context, label)

            fm = self._build_frontmatter(
                f"Daily Reflection — {date}", date, "daily_reflection", "philosopher",
                "Daily reflection synthesized by the philosopher agent — patterns noticed, key decisions, learnings worth remembering, and memory update suggestions.",
                extra={"status": "pending"},
            )
            self._writer.write(_scope.target, f"{fm}\n\n{reflection_content}")
            _scope.extra_details["chars"] = len(reflection_content)
            log.warning("[%s] Generated successfully", label)
            return {
                "date": date,
                "reflection": reflection_content,
                "status": "pending",
                "path": reflection_path,
            }

    # ── Weekly reflection ──────────────────────────────────────────

    async def run_weekly_reflection(self, week_end: str | None = None) -> dict:
        """Run weekly reflection. Contract-gated: `generate_reflection`."""
        from datetime import timedelta

        if not self._check_contract("generate_reflection"):
            return {"status": "blocked_by_contract"}

        if not week_end:
            from agntspace.infra.timezone import local_today
            week_end = local_today()

        end_date = datetime.strptime(week_end, "%Y-%m-%d")
        week_num = end_date.isocalendar()[1]
        week_label = f"{end_date.year}-W{week_num:02d}"
        _AGNT = "journal/agnt"
        _USER = "journal/user"

        def _resolve_a(filename: str) -> str:
            return f"{_AGNT}/{filename}"

        def _resolve_u(date_str: str) -> str:
            return f"{_USER}/user-journal_{date_str}.md"

        reflection_path = _resolve_a(f"weekly-reflection_{week_label}.md")
        label = f"Weekly reflection ({week_label})"

        # Skip if already generated successfully
        if self._reader.exists(reflection_path) and not self._is_fallback_file(reflection_path):
            log.warning("[%s] Already exists, skipping", label)
            doc = self._reader.read(reflection_path)
            return {
                "week": week_label,
                "reflection": doc.content,
                "status": doc.metadata.get("status", "pending"),
                "path": reflection_path,
            }

        # Gather all available daily content for the week
        day_entries: list[str] = []
        days_with_data = 0

        for i in range(7):
            d = end_date - timedelta(days=i)
            date_str = d.strftime("%Y-%m-%d")
            day_parts: list[str] = [f"## {date_str}"]
            has_content = False

            # User journal
            journal_path = _resolve_u(date_str)
            if self._reader.exists(journal_path):
                try:
                    doc = self._reader.read(journal_path)
                    if doc.content.strip():
                        day_parts.append(doc.content)
                        has_content = True
                except Exception:
                    pass

            # Activity summary from logger
            activity_logger = getattr(self, "_activity", None)
            if activity_logger:
                try:
                    activity_summary = activity_logger.get_daily_summary(date_str)
                    if activity_summary:
                        day_parts.append(f"### System Activity\n{activity_summary[:1500]}")
                        has_content = True
                except Exception:
                    pass

            # Agent observations
            agent_path = _resolve_a(f"agent-observations_{date_str}.md")
            if self._reader.exists(agent_path):
                try:
                    agent_doc = self._reader.read(agent_path)
                    day_parts.append(f"### Agent Observations\n{agent_doc.content}")
                    has_content = True
                except Exception:
                    pass

            # EOD report
            eod_path = _resolve_a(f"eod-report_{date_str}.md")
            if self._reader.exists(eod_path):
                try:
                    eod_doc = self._reader.read(eod_path)
                    if not eod_doc.metadata.get("generation_failed", False):
                        day_parts.append(f"### EOD Report\n{eod_doc.content}")
                        has_content = True
                except Exception:
                    pass

            # Daily reflection
            refl_path = _resolve_a(f"daily-reflection_{date_str}.md")
            if self._reader.exists(refl_path):
                try:
                    refl_doc = self._reader.read(refl_path)
                    if not refl_doc.metadata.get("generation_failed", False):
                        day_parts.append(f"### Daily Reflection\n{refl_doc.content}")
                        has_content = True
                except Exception:
                    pass

            if has_content:
                days_with_data += 1
            else:
                day_parts.append("(No data available for this day.)")

            day_entries.append("\n\n".join(day_parts))

        combined = "\n\n---\n\n".join(reversed(day_entries))

        availability_note = ""
        if days_with_data == 0:
            availability_note = (
                "\nNOTE: No journal data was found for any day this week. "
                "Produce a minimal weekly reflection noting the gap and suggesting "
                "the user start journaling.\n"
            )
        elif days_with_data < 4:
            availability_note = (
                f"\nNOTE: Only {days_with_data}/7 days had data. "
                "Note the sparse coverage and work with what's available.\n"
            )

        from agntspace.journal.format_loader import build_task_instruction

        _FALLBACK_WEEKLY_REFL = (
            f"Produce a weekly reflection for the week ending {week_end} "
            "using EXACTLY this markdown format:\n\n"
            "## Week Summary\nWhat was the overall theme or focus this week? (2-3 sentences)\n\n"
            "## Progress\n- What moved forward\n- What stalled or got blocked\n\n"
            "## Patterns\n- Positive behavioral patterns observed\n- Concerning patterns to watch\n\n"
            "## Goal Check\n- Goal that needs attention — why\n\n"
            "## Key Takeaways\n- Takeaway to carry into next week\n\n"
            "---\n" + availability_note +
            "RULES: Output ONLY valid markdown. Use ## headers exactly as shown. "
            "Use bullet lists (- item) for lists. Focus on patterns and progress, "
            "not daily details. Never output JSON."
        )
        task_instruction = build_task_instruction(
            self._skill_loader, "weekly-reflection",
            preamble=f"Produce a weekly reflection for the week ending {week_end} using EXACTLY this markdown format:",
            suffix=availability_note,
            fallback=_FALLBACK_WEEKLY_REFL,
        )

        from agntspace.activity.scoped import scoped_operation

        async with scoped_operation(
            writer=self._writer,
            contract=self._contract,
            decisions=self._decisions,
            activity=self._activity,
            actor="philosopher",
            action_type="weekly_reflection",
            action_target=reflection_path,
            reason="weekly_reflection",
            scope_prefix="weekly-reflection",
            rationale=f"Weekly reflection for {week_label}",
            caller="daily_cycle",
            skill_context=["weekly-reflection"],
        ) as _scope:
            response_content = await self._call_agent(task_instruction, combined, label)

            fm = self._build_frontmatter(
                f"Weekly Reflection — {week_label}", end_date.strftime("%Y-%m-%d"),
                "weekly_reflection", "philosopher",
                "Weekly reflection synthesized by the philosopher agent — recurring patterns, goal progress, evolution of priorities, and key takeaways from the past 7 days.",
                extra={"status": "pending"},
            )
            self._writer.write(_scope.target, f"{fm}\n\n{response_content}")
            _scope.extra_details["days_with_data"] = days_with_data
            _scope.extra_details["chars"] = len(response_content)
            log.warning("[%s] Generated successfully (%d days with data)", label, days_with_data)

            # Trigger layered memory compaction
            if self._memory_engine and hasattr(self._memory_engine, "compact_memory"):
                try:
                    await self._memory_engine.compact_memory()
                    log.warning("All memory layers compacted during weekly reflection")
                except Exception as exc:
                    log.warning("Memory compaction failed during weekly reflection: %s", exc)

            return {
                "week": week_label,
                "reflection": response_content,
                "status": "pending",
                "path": reflection_path,
            }

    # ── Approve / list ─────────────────────────────────────────────

    async def approve_reflection(self, path: str) -> None:
        """Approve a reflection and apply its MEMORY.md updates."""
        if not self._reader.exists(path):
            raise FileNotFoundError(f"Reflection not found: {path}")

        doc = self._reader.read(path)

        # Update status to approved
        content = doc.raw.replace("status: pending", "status: approved")
        self._writer.write(path, content, trust_reason="reflection_approve")

        # Extract suggested MEMORY.md updates and append them
        memory_updates = self._extract_memory_updates(doc.content)
        if memory_updates and self._reader.exists("memory/MEMORY.md"):
            self._writer.append("memory/MEMORY.md", "\n" + memory_updates, trust_reason="reflection_approve")
            log.warning("MEMORY.md updated from reflection: %s", path)

    def list_reflections(self, limit: int = 20) -> list[dict]:
        """List reflection files from journal/agnt/."""
        results = []
        for f in self._reader.list_files("journal/agnt", "*reflection*.md"):
            try:
                doc = self._reader.read(f"journal/agnt/{f.name}")
                results.append({
                    "path": f"journal/agnt/{f.name}",
                    "title": doc.metadata.get("title", f.stem),
                    "status": doc.metadata.get("status", "unknown"),
                })
            except Exception:
                continue
        results.sort(key=lambda x: x["path"], reverse=True)
        return results[:limit]

    def _extract_memory_updates(self, content: str) -> str:
        """Extract suggested MEMORY.md updates from reflection content.

        The section header to search for is loaded from the daily-reflection
        skill's ``config/format.yaml`` (``memory_updates_section`` key).
        Falls back to matching "Memory Updates" in the header text.
        """
        from agntspace.journal.format_loader import get_memory_updates_section

        target_section = get_memory_updates_section(self._skill_loader)
        lines = []
        in_section = False
        for line in content.split("\n"):
            if target_section in line or "Memory Updates" in line:
                in_section = True
                continue
            if in_section:
                if line.startswith("##"):
                    break
                if line.strip().startswith("- "):
                    lines.append(line.strip())
        return "\n".join(lines)
