"""
Ollama Local LLM Service
Generates LinkedIn posts using a locally-running Ollama model.

Requires Ollama running at OLLAMA_BASE_URL (default: http://localhost:11434).
Set OLLAMA_MODEL to choose the model (default: llama3.2).

Start Ollama: ollama serve
Pull a model: ollama pull qwen2.5:14b
"""

import os
import re

import logging
from typing import Any, Optional, Literal

import ollama

import json
from services.shared import PERSONA_SYSTEM_PROMPT, YOUTUBE_SHORT_SYSTEM_PROMPT, SSI_COMPONENT_INSTRUCTIONS, X_CHAR_LIMIT, X_URL_CHARS, THREADS_CHAR_LIMIT, clean_llm_text, format_post_paragraphs
from services.console_grounding import build_grounding_facts_block, ProjectFact, truth_gate
from services.avatar_intelligence import build_extracted_grounding_context, ExtractedEvidenceFact

logger = logging.getLogger(__name__)

DEFAULT_MODEL = "llama3.2"
DEFAULT_BASE_URL = "http://localhost:11434"
DEFAULT_NUM_CTX = 16384


def _load_ollama_think() -> bool | Literal['low', 'medium', 'high'] | None:
    """Read Ollama thinking-mode config.

    Gemma 4 can return empty visible content for longer prompts unless
    thinking is explicitly disabled through Ollama's native chat API.
    """
    raw = (os.getenv("OLLAMA_THINK", "false") or "").strip().lower()
    if raw in {"", "none", "auto", "default"}:
        return None
    if raw in {"true", "1", "yes", "on"}:
        return True
    if raw in {"false", "0", "no", "off"}:
        return False
    if raw in {"low", "medium", "high"}:
        return raw  # type: ignore[return-value]
    logger.warning("Invalid OLLAMA_THINK=%r; using false", raw)
    return False



class OllamaService:
    think: bool | Literal['low', 'medium', 'high'] | None

    def generate_youtube_short_script(
        self,
        title: str,
        angle: str,
        ssi_component: str,
        grounding_facts: Optional[list[ProjectFact]] = None,
        interactive: bool = False,
        continuity_context: str = "",
        channel: str = "youtube",
    ) -> str:
        """
        Generate a YouTube Short script using the dedicated persona prompt and structure.
        Handles empty LLM output robustly, with fallback model support.
        """
        grounding_block = build_grounding_facts_block(grounding_facts or [])
        _continuity_block = (
            f"\n\nContinuity context (avoid repeating these recent topics; build on or contrast them):\n{continuity_context}"
            if continuity_context
            else ""
        )
        system_prompt = f"{YOUTUBE_SHORT_SYSTEM_PROMPT}\n{_continuity_block}"
        user_prompt = f"Topic: {title}\nAngle: {angle}\n\n{grounding_block}\n\nWrite a YouTube Short script for this topic."

        text = self._chat(system_prompt, user_prompt, max_tokens=768)

        if not text:
            return "[ERROR] No script was generated by the LLM (main or fallback). Please check your Ollama model, prompt, or try again."

        # Run truth gate with correct channel for logging and filtering
        text = truth_gate(text, f"{title}. {angle}", grounding_facts or [], interactive=interactive, channel=channel)

        # Hard cap: truncate to last complete sentence at or before 500 chars
        if len(text) > 500:
            truncated = text[:500]
            for sep in (".", "!", "?"):
                idx = truncated.rfind(sep)
                if idx != -1:
                    truncated = truncated[: idx + 1]
                    break
            else:
                truncated = truncated[: truncated.rfind(" ")].rstrip()
            text = truncated
            logger.debug(f"YouTube Short script truncated to {len(text)} chars")

        return text

    def __init__(self, model: str = DEFAULT_MODEL, base_url: str = DEFAULT_BASE_URL):
        self.model = model
        self.base_url = base_url
        raw_num_ctx = (os.getenv("OLLAMA_NUM_CTX") or str(DEFAULT_NUM_CTX)).strip()
        try:
            self.num_ctx = max(1024, int(raw_num_ctx))
        except ValueError:
            logger.warning("Invalid OLLAMA_NUM_CTX=%r; using default %d", raw_num_ctx, DEFAULT_NUM_CTX)
            self.num_ctx = DEFAULT_NUM_CTX
        self.think = _load_ollama_think()
        self.client = ollama.Client(host=base_url)
        logger.info(
            "OllamaService initialised - model=%s, host=%s, num_ctx=%d, think=%s",
            model,
            base_url,
            self.num_ctx,
            self.think,
        )

    def _chat(self, system_prompt: str, user_prompt: str, max_tokens: int = 1024) -> str:
        """Send a chat request to Ollama and return the response text.

        On empty output or model error, automatically retries once with
        OLLAMA_MODEL_FALLBACK (if set and different from the primary model).
        """
        fallback = os.getenv("OLLAMA_MODEL_FALLBACK", "").strip()
        use_fallback = bool(fallback and fallback != self.model)

        def _attempt(model: str) -> str:
            response = self.client.chat(
                model=model,
                think=self.think,
                options={"num_predict": max_tokens, "num_ctx": self.num_ctx},
                messages=[
                    {"role": "system", "content": system_prompt},
                    {"role": "user",   "content": user_prompt},
                ],
            )
            return (response.message.content or "").strip()

        def _run_fallback(reason: str) -> str:
            logger.warning("Model '%s' %s — retrying with fallback '%s'", self.model, reason, fallback)
            try:
                return _attempt(fallback)
            except ollama.ResponseError as fe:
                raise RuntimeError(f"Ollama API error (fallback model={fallback}): {fe}") from fe
            except Exception as fe:
                raise RuntimeError(
                    f"Could not reach Ollama at {self.base_url} (fallback '{fallback}'). "
                    "Is it running? Try: ollama serve"
                ) from fe

        try:
            text = _attempt(self.model)
        except ollama.ResponseError as e:
            if use_fallback:
                return _run_fallback(f"API error: {e}")
            raise RuntimeError(f"Ollama API error (model={self.model}): {e}") from e
        except Exception as e:
            if use_fallback:
                return _run_fallback(f"unreachable: {e}")
            raise RuntimeError(
                f"Could not reach Ollama at {self.base_url}. "
                "Is it running? Try: ollama serve"
            ) from e

        if not text and use_fallback:
            return _run_fallback("returned empty output")

        return text

    def chat_as_persona(
        self,
        messages: list[dict[str, str]],
        grounding_context: str = "",
        max_tokens: int = 600,
    ) -> str:
        """Run an interactive persona chat with avatar grounding context."""
        _ctx_block = f"\n\nGrounding context (your projects and background):\n{grounding_context}" if grounding_context else ""
        system_prompt = f"""{PERSONA_SYSTEM_PROMPT}{_ctx_block}

You are in interactive console chat mode.
- Identity lock: You MUST represent the person described in Profile context.
- Never claim to be Gemma, Google, DeepMind, a generic LLM, or an AI assistant.
- If asked who you are, introduce yourself using the profile name/title/projects from Profile context.
- Treat Profile context as the primary source of truth for biography and projects.
- If you are uncertain about a fact, say it is not confirmed in loaded profile context.
- Stay in persona and answer naturally.
- Be concrete and practical when possible.
- Output plain text only (no markdown)."""

        normalized_messages: list[dict[str, str]] = []
        for m in messages:
            role = (m.get("role") or "user").strip()
            if role not in {"user", "assistant"}:
                continue
            content = (m.get("content") or "").strip()
            if content:
                normalized_messages.append({"role": role, "content": content})

        try:
            response = self.client.chat(
                model=self.model,
                think=self.think,
                options={"num_predict": max_tokens, "num_ctx": self.num_ctx},
                messages=[{"role": "system", "content": system_prompt}, *normalized_messages],
            )
            return clean_llm_text((response.message.content or "").strip())
        except ollama.ResponseError as e:
            raise RuntimeError(f"Ollama API error (model={self.model}): {e}") from e
        except Exception as e:
            raise RuntimeError(
                f"Could not reach Ollama at {self.base_url}. "
                "Is it running? Try: ollama serve"
            ) from e

    def generate_linkedin_post(
        self,
        title: str,
        angle: str,
        ssi_component: str,
        hashtags: list,
        grounding_facts: Optional[list[ProjectFact]] = None,
        max_length: int = 1300,
        channel: str = "linkedin",
        interactive: bool = False,
        continuity_context: str = "",
    ) -> str:
        """
        Generate a LinkedIn post optimised for a specific SSI component.
        """
        ssi_instruction = SSI_COMPONENT_INSTRUCTIONS.get(
            ssi_component, SSI_COMPONENT_INSTRUCTIONS["establish_brand"]
        )
        hashtag_str = " ".join(f"#{h.lstrip('#')}" for h in hashtags)

        if channel == "x":
            max_length = X_CHAR_LIMIT
            _platform_block = f"""\nIMPORTANT â€” this post is for X (Twitter), NOT LinkedIn:
- Hard limit: {X_CHAR_LIMIT} characters total (count every character including spaces and punctuation)
- Write ONE tight paragraph only â€” no multi-paragraph structure
- No hashtags â€” they will NOT be appended for X
- No 'Read more' or filler CTAs â€” the post must stand completely alone
- Every word must earn its place; cut ruthlessly until it fits
"""
        elif channel == "threads":
            max_length = THREADS_CHAR_LIMIT
            _platform_block = f"""\nIMPORTANT â€” this post is for Threads, NOT LinkedIn:
- Hard limit: {THREADS_CHAR_LIMIT} characters total (count every character including spaces and punctuation)
- Write 1-3 tight sentences or two very short paragraphs
- No hashtags â€” they will NOT be appended for Threads
- Keep it conversational, direct, and human; the post should feel like a thought someone would actually reply to
- Do NOT use LinkedIn-style structure, professional polish, or filler CTAs
"""
        elif channel == "youtube":
            max_length = 500
            _platform_block = f"\n{YOUTUBE_SHORT_SYSTEM_PROMPT}"
        else:
            _platform_block = ""

        grounding_block = build_grounding_facts_block(grounding_facts or [])

        _continuity_block = (
            f"\n\nContinuity context (avoid repeating these recent topics; build on or contrast them):\n{continuity_context}"
            if continuity_context
            else ""
        )

        system_prompt = f"""{PERSONA_SYSTEM_PROMPT}
Maximum length: {max_length} characters including hashtags.{_platform_block}
SSI optimisation goal:
{ssi_instruction}{_continuity_block}"""

        platform_label = {
            "x": "X post",
            "bluesky": "Bluesky post",
            "threads": "Threads post",
            "youtube": "YouTube Short script",
        }.get(channel, "LinkedIn post")

        user_prompt = f"""Write a {platform_label} about: {title}
Angle to take: {angle}

{grounding_block}

Balance rules (non-negotiable):
- Every factual claim must come from the topic description or the profile facts above. Do not invent stats, dates, company names, or metrics.
- If you mention a specific project by name, only attribute technologies or challenges that appear in that project's Detail field above. Never transfer concepts from the article onto a project unless they are explicitly listed in its detail.
- Reference your own background at most once, and only when it maps directly to a profile fact provided. If none connect naturally, skip the personal reference.
- Use a hook in the first line that stops the scroll â€” a surprising claim, a bold question, or a short scene.
- The post should feel authentic to someone who actually built this, not generic AI content.
Do NOT include hashtags in your output â€” they will be appended automatically."""

        text = self._chat(system_prompt, user_prompt, max_tokens=768)

        # Lightweight truth gate â€” strip sentences with unsupported claims
        text = truth_gate(text, f"{title}. {angle}", grounding_facts or [], interactive=interactive, channel=channel)

        # Format into paragraphs with hashtags on their own line
        if channel in ("linkedin",):
            text = format_post_paragraphs(text)

        if channel == "youtube" and len(text) > 500:
            # Hard cap: truncate to last complete sentence at or before 500 chars
            truncated = text[:500]
            for sep in (".", "!", "?"):
                idx = truncated.rfind(sep)
                if idx != -1:
                    truncated = truncated[: idx + 1]
                    break
            else:
                # No sentence boundary found â€” cut at last word boundary
                truncated = truncated[: truncated.rfind(" ")].rstrip()
            text = truncated
            logger.debug(f"YouTube Short script truncated to {len(text)} chars")

        return text

    def generate_thread_posts(
        self,
        article_text: str,
        source_url: str,
        ssi_component: str = "engage_with_insights",
        channel: str = "x",
    ) -> "Optional[list[str]]":
        """
        Generate a 2-post thread (X or Bluesky) from an article.
        Uses Ollama structured JSON output (format schema) for reliable splitting â€”
        no regex parsing required.
        Returns a list of exactly 2 strings, or None if article_text is too short.
        """
        if not article_text or len(article_text.strip()) < 100:
            logger.warning(f"Skipping thread generation â€” article text too short: {source_url}")
            return None

        ssi_instruction = SSI_COMPONENT_INSTRUCTIONS.get(ssi_component, SSI_COMPONENT_INSTRUCTIONS["engage_with_insights"])
        platform = "Bluesky" if channel == "bluesky" else "X (Twitter)"
        char_limit = X_CHAR_LIMIT - X_URL_CHARS

        github_user = os.getenv("GITHUB_USER", "")
        github_url = f"github.com/{github_user}" if github_user else "your GitHub profile"

        prompt = f"""Generate a 2-post {platform} thread from the article below.

post_1 (hook): bold claim, surprising stat, or sharp question that stops the scroll. Max {char_limit} chars.
post_2 (insight + close): your technical take, ending with {github_url} and a CTA. Max {char_limit} chars.

Rules: plain text only, no hashtags, no markdown, no "1/2"/"2/2" numbering.

SSI goal: {ssi_instruction}

Article: {article_text[:3000]}"""

        _thread_schema = {
            "type": "object",
            "properties": {
                "post_1": {"type": "string"},
                "post_2": {"type": "string"},
            },
            "required": ["post_1", "post_2"],
        }

        def _thread_attempt(model: str) -> "Optional[list[str]]":
            resp = self.client.chat(
                model=model,
                think=self.think,
                options={"num_predict": 600, "num_ctx": self.num_ctx},
                format=_thread_schema,
                messages=[
                    {"role": "system", "content": PERSONA_SYSTEM_PROMPT},
                    {"role": "user",   "content": prompt},
                ],
            )
            data = json.loads(resp.message.content or "{}")
            return [clean_llm_text(data["post_1"]), clean_llm_text(data["post_2"])]

        fallback = os.getenv("OLLAMA_MODEL_FALLBACK", "").strip()
        use_fallback = bool(fallback and fallback != self.model)

        try:
            return _thread_attempt(self.model)
        except Exception as e:
            if use_fallback:
                logger.warning(
                    "Thread generation failed for '%s' (%s) — retrying with fallback '%s': %s",
                    self.model, source_url, fallback, e,
                )
                try:
                    return _thread_attempt(fallback)
                except Exception as fe:
                    logger.warning("Fallback thread generation also failed (%s): %s", fe, source_url)
            else:
                logger.warning("Ollama thread generation failed (%s): %s", e, source_url)
            return None

    def generate_first_comment(self, post_text: str, source_url: str) -> str:
        """
        Generate a LinkedIn first comment with hashtags and source link.
        """
        prompt = f"""Write a LinkedIn first comment for the post below.

The comment should contain:
- 3-5 relevant hashtags (space-separated)
- The source URL: {source_url}
- Optionally 1 short sentence that adds context or invites engagement

Keep it concise â€” the post body carries the main content.
Output plain text only â€” no Markdown.

Post:
{post_text}"""

        comment = self._chat(PERSONA_SYSTEM_PROMPT, prompt, max_tokens=200)
        if source_url and source_url not in comment:
            comment = comment.rstrip() + f"\n\n{source_url}"
        return comment

    def summarise_for_curation(
        self,
        article_text: str,
        source_url: str,
        ssi_component: str = "engage_with_insights",
        channel: str = "linkedin",
        post_mode: bool = False,
        grounding_facts: Optional[list[ProjectFact]] = None,
        extracted_facts: Optional[list[ExtractedEvidenceFact]] = None,
        interactive: bool = False,
        continuity_context: str = "",
        github_context: str = "",
    ) -> Optional[str]:
        """
        Summarise a curated article into a LinkedIn post with personal commentary.
        Returns None if article_text is too short to be useful.
        post_mode=True: omits hashtags and URL from the body â€” both go in the first comment instead.
        """
        if not article_text or len(article_text.strip()) < 100:
            logger.warning(f"Skipping curation â€” article text too short ({len(article_text.strip())} chars): {source_url}")
            return None
        ssi_instruction = SSI_COMPONENT_INSTRUCTIONS.get(ssi_component, SSI_COMPONENT_INSTRUCTIONS["engage_with_insights"])

        if channel == "x":
            _text_budget = X_CHAR_LIMIT - X_URL_CHARS
            format_instructions = f"""IMPORTANT â€” this post is for X (Twitter), NOT LinkedIn:
- Hard limit: {_text_budget} characters for your text (the source URL adds {X_URL_CHARS} chars, totalling {X_CHAR_LIMIT})
- Write 1-3 tight sentences â€” no paragraphs, no structure, no hashtags
- Ground your take in something SPECIFIC from this article: a number, a technique, a concrete claim, or a decision. Do NOT write a generic AI observation.
- Forbidden openers: "Everyone thinks", "Scaling AI", "AI is", "The future of", "Everyone knows" â€” be specific to what this article actually covers
- Sound like someone who shipped it: direct, technical, occasionally contrarian
- Do NOT start with a quotation mark character
Do NOT include the article URL â€” it will be appended automatically."""
        elif channel == "threads":
            _url_overhead = (2 + len(source_url)) if source_url else 0
            _text_budget = max(80, THREADS_CHAR_LIMIT - _url_overhead)
            format_instructions = f"""IMPORTANT â€” this post is for Threads, NOT LinkedIn:
- Hard character limit: {_text_budget} characters for your text ({_url_overhead} chars reserved for the URL, total max = {THREADS_CHAR_LIMIT})
- Write 1-3 complete sentences or two very short paragraphs. Target under {_text_budget - 40} characters â€” leave margin so nothing gets cut.
- Keep it conversational and direct. It should feel like a sharp thought someone might reply to, not a LinkedIn mini-essay.
- No hashtags, no bullets, no markdown, and do NOT start with a quotation mark character.
Do NOT include the article URL â€” it will be appended automatically."""
        elif channel == "bluesky":
            _url_overhead = 2 + len(source_url)
            _text_budget = 300 - _url_overhead
            format_instructions = f"""IMPORTANT â€” this post is for Bluesky, NOT LinkedIn:
- Hard character limit: {_text_budget} characters for your text ({_url_overhead} chars reserved for the URL, total max = 300)
- Write 1-2 short complete sentences. Target under {_text_budget - 40} characters â€” leave margin so nothing gets cut.
- ONE RULE ABOVE ALL: every sentence MUST end with a period, exclamation mark, or question mark. Never end mid-thought.
- Ground your take in something SPECIFIC from this article â€” no generic AI observations
- No hashtags, no bullet points, do NOT start with a quotation mark character
Do NOT include the article URL â€” it will be appended automatically."""
        elif channel == "youtube":
            format_instructions = f"""{YOUTUBE_SHORT_SYSTEM_PROMPT}
CRITICAL HARD LIMIT: your entire response MUST be 500 characters or fewer â€” count every character before you output.
Do NOT include hashtags, URLs, or any markdown.
If you are close to 500 characters, stop at the last complete sentence that fits."""
        elif post_mode:
            format_instructions = """Output rules:
- Plain text only â€” no Markdown, no **, no ##, no backticks. LinkedIn does not render Markdown.
- 4-8 sentences in short punchy paragraphs.
- 3-5 hashtags on the last line.
- Do NOT include the article URL â€” it will be appended automatically.

Balance rules (non-negotiable):
- Every factual claim must come from either the article text or the profile facts below. If a stat, date, company name, or metric is not explicitly present in those sources, do not include it.
- If you mention a specific project by name, only attribute technologies or challenges that appear in that project's Detail field. Never transfer concepts from the article onto a project unless they are explicitly listed in its detail.
- Include at least one specific detail from the article: a model name, number, benchmark, technique, or decision.
- Reference your own background at most once per post, and only when it maps directly to one of the profile facts provided. If none connect naturally, write the post without a personal reference.
- Do not invent percentages, performance numbers, timelines, or outcomes that are not stated in the article or profile facts."""
        else:
            format_instructions = """Output rules:
- Plain text only â€” no Markdown, no **, no ##, no backticks. LinkedIn does not render Markdown.
- 4-8 sentences in short punchy paragraphs.
- 3-5 hashtags on the last line.
- Do NOT include the article URL in your output â€” it will be appended automatically.

Balance rules (non-negotiable):
- Every factual claim must come from either the article text or the profile facts below. If a stat, date, company name, or metric is not explicitly present in those sources, do not include it.
- If you mention a specific project by name, only attribute technologies or challenges that appear in that project's Detail field. Never transfer concepts from the article onto a project unless they are explicitly listed in its detail.
- Include at least one specific detail from the article: a model name, number, benchmark, technique, or decision.
- Reference your own background at most once per post, and only when it maps directly to one of the profile facts provided. If none connect naturally, write the post without a personal reference.
- Do not invent percentages, performance numbers, timelines, or outcomes that are not stated in the article or profile facts."""

        grounding_block = build_grounding_facts_block(grounding_facts or [])

        # Inject recently extracted knowledge as additional grounding context.
        _extracted_context = ""
        if extracted_facts:
            _limit = int(os.getenv("EXTRACTED_CONTEXT_LIMIT", "10"))
            art_tokens = set(re.findall(r"[a-zA-Z0-9]{3,}", article_text.lower()))
            relevant = [
                f for f in extracted_facts
                if art_tokens & set(re.findall(r"[a-zA-Z0-9]{3,}", f.statement.lower()))
            ] or extracted_facts
            _extracted_context = build_extracted_grounding_context(relevant[:_limit])

        _continuity_block = (
            f"\n\nContinuity context (avoid repeating these recent topics; build on or contrast them):\n{continuity_context}"
            if continuity_context
            else ""
        )

        _extracted_block = f"\n\n{_extracted_context}" if _extracted_context else ""

        prompt = f"""Article:
---
{article_text[:4500]}
---

Write a {"Threads post" if channel == "threads" else "Bluesky post" if channel == "bluesky" else "X post" if channel == "x" else "YouTube Short script" if channel == "youtube" else "LinkedIn post"} reacting to this article.

{ssi_instruction}

{grounding_block}{_extracted_block}

{format_instructions}{_continuity_block}"""

        result = clean_llm_text(self._chat(
            PERSONA_SYSTEM_PROMPT + (f"\n\n{github_context}" if github_context else ""),
            prompt,
            max_tokens=800,
        ))

        # Lightweight truth gate â€” strip sentences with unsupported claims
        if result:
            result = truth_gate(result, article_text, grounding_facts or [], interactive=interactive, channel=channel)

        # Format into paragraphs with hashtags on their own line
        if result and channel == "linkedin":
            result = format_post_paragraphs(result)

        if result:
            return result

        logger.warning(
            "Curation output was empty after cleanup â€” skipping article: %s", source_url
        )
        return None

