import base64
import json
import logging
import os
import re
from textwrap import indent
from typing import Any, Dict, List, NamedTuple, Optional, Tuple, TypeVar, Union

import requests
import yaml
from markdown import markdown
from pydantic import BaseModel, Field

from multiqc import config, report
from multiqc.core.log_and_rich import run_with_spinner
from multiqc.types import Anchor, SampleName
from multiqc.utils import config_schema

logger = logging.getLogger(__name__)


_MULTIQC_DESCRIPTION = """\
You are an expert in bioinformatics, sequencing technologies, genomics data analysis, and adjacent fields.

You are given findings from a MultiQC report, generated by a bioinformatics workflow.
MultiQC supports various bioinformatics tools that output QC metrics, and aggregates those metrics
into a single report. It outputs a "General Statistics" table with key metrics for each sample across
all tools. That table is followed by more detailed sections from specific tools, that can include tables,
as well as plots of different types (bar plot, line plot, scatter plot, heatmap, etc.)
"""

_PROMPT_BASE = (
    _MULTIQC_DESCRIPTION
    + """
You are given data from such a report. Your task is to analyse the data, and
give 1-2 bullet points of a very short and concise overall summary for the results.
Don't waste words: mention only the important QC issues. If there are no issues, just say so.
Just print one or two bullet points, nothing else.
Please do not add any extra headers to the response.

Use markdown to format your reponse for readability. Use directives with pre-defined classes
.text-green, .text-red, and .text-yellow to highlight severity, e.g. :span[39.2%]{.text-red}.
Highlight any mentioned sample names or sample named prefixes or suffixes with a sample directive,
and make sure to use the same color classes for severity, e.g. :sample[A1001.2003]{.text-yellow}
or :sample[A1001]{.text-yellow}. Do not put multiple sample names inside one directive.

You must use only multiples of 4 spaces to indent nested lists.
"""
)

_SHORT_EXAMPLE = """
Two examples of short summaries:

- :span[11/13 samples]{.text-green} show consistent metrics within expected ranges.
- :sample[A1001.2003]{.text-red} and :sample[A1001.2004]{.text-red} exhibit extremely high percentage of :span[duplicates]{.text-red} (:span[65.54%]{.text-red} and :span[83.14%]{.text-red}, respectively).

- All samples show good quality metrics with :span[75.7-77.0%]{.text-green} CpG methylation and :span[76.3-86.0%]{.text-green} alignment rates
- :sample[2wk]{.text-yellow} samples show slightly higher duplication (:span[11-15%]{.text-yellow}) compared to :sample[1wk]{.text-green} samples (:span[6-9%]{.text-green})'
"""

_PROMPT_FULL_DETAILED_SUMMARY_INSTRUCTIONS = """
Next, generate a more detailed summary of the results, and your recommendations for the next steps.

Make sure to use a multiple of 4 spaces to indent nested lists.

Highlight severity of the values and the sample names with directives and the pre-defined classes, similar to the short summary,
as in the example below.
"""

_EXAMPLE_SUMMARY_FOR_FULL = """\
- :span[11/13 samples]{.text-green} show consistent metrics within expected ranges.
- :sample[A1001.2003]{.text-red} and :sample[A1001.2004]{.text-red} exhibit extremely high percentage of :span[duplicates]{.text-red} (:span[65.54%]{.text-red} and :span[83.14%]{.text-red}, respectively).
"""

_PROMPT_EXAMPLE_SUMMARY_FOR_FULL = f"""\
Example, formatted as YAML of 2 sections (summary, detailed_analysis):

summary: |
{indent(_EXAMPLE_SUMMARY_FOR_FULL, "    ")}
"""

_EXAMPLE_DETAILED_SUMMARY = """\
**Analysis**

- :sample[A1002]{.text-yellow} and :sample[A1003]{.text-yellow} groups (:span[11/13 samples]{.text-green}) show good quality metrics, with consistent GC content (38-39%), read lengths (125 bp), and acceptable levels of duplicates and valid pairs.
- :sample[A1001.2003]{.text-red} and :sample[A1001.2004]{.text-red} show severe quality issues:
    - Extremely high duplicate rates (:span[65.54%]{.text-red} and :span[83.14%]{.text-red})
    - Low percentages of valid pairs (:span[37.2%]{.text-red} and :span[39.2%]{.text-red})
    - High percentages of failed modules in FastQC (:span[33.33%]{.text-red})
    - Significantly higher total sequence counts (:span[141.9M]{.text-red} and :span[178.0M]{.text-red}) compared to other samples
    - FastQC results indicate that :sample[A1001.2003]{.text-red} and :sample[A1001.2004]{.text-red} have a slight :span[GC content]{.text-red} bias at 39.5% against most other samples having 38.0%, which indicates a potential contamination that could be the source of other anomalies in quality metrics.

- :sample[A1002-1007]{.text-yellow} shows some quality concerns:
    - Low percentage of valid pairs (:span[48.08%]{.text-yellow})
    - Low percentage of passed Di-Tags (:span[22.51%]{.text-yellow})

- Overrepresented sequences analysis reveals adapter contamination in several samples, particularly in :sample[A1001.2003]{.text-yellow} (up to :span[35.82%]{.text-yellow} in Read 1).
- HiCUP analysis shows that most samples have acceptable levels of valid pairs, with :sample[A1003]{.text-green} group generally performing better than :sample[A1002]{.text-yellow} group.

**Recommendations**

- Remove :sample[A1001.2003]{.text-red} and :sample[A1200.2004]{.text-red} from further analysis due to severe quality issues.
- Investigate the cause of low valid pairs and passed Di-Tags in :sample[A1002-1007]{.text-yellow}. Consider removing it if the issue cannot be resolved.
- Perform adapter trimming on all samples, particularly focusing on :sample[A1001]{.text-red} group.
- Re-run the Hi-C analysis pipeline after removing problematic samples and performing adapter trimming.
- Investigate the cause of higher duplication rates in :sample[A1002]{.text-yellow} group compared to :sample[A1003]{.text-green} group, although they are still within acceptable ranges.
- Consider adjusting the Hi-C protocol or library preparation steps to improve the percentage of valid pairs, especially for :sample[A1002]{.text-yellow} group.
"""

_PROMPT_EXAMPLE_DETAILED_SUMMARY = f"""\
detailed_analysis: |
{indent(_EXAMPLE_DETAILED_SUMMARY, "    ")}
"""

PROMPT_SHORT = f"""\
{_PROMPT_BASE}

{_SHORT_EXAMPLE}
"""

PROMPT_FULL = f"""\
{_PROMPT_BASE}

{_PROMPT_FULL_DETAILED_SUMMARY_INSTRUCTIONS}

{_PROMPT_EXAMPLE_SUMMARY_FOR_FULL}

{_PROMPT_EXAMPLE_DETAILED_SUMMARY}
"""


class InterpretationOutput(BaseModel):
    summary: str = Field(description="A very short and concise overall summary")
    detailed_analysis: Optional[str] = Field(description="Detailed analysis", default=None)

    def markdown_to_html(self, text: str) -> str:
        """
        Convert markdown to HTML
        """
        # First convert pseudonyms back to original names if needed
        text = deanonymize_sample_names(text)

        html = markdown(text)
        # Find and replace directives :span[1.23%]{.text-red} -> <span..., handle multiple matches in one string
        html = re.sub(
            r":span\[([^\]]+?)\]\{\.text-(green|red|yellow)\}",
            r"<span class='text-\2'>\1</span>",
            html,
        )
        # similarly, find and replace directives :sample[A1001.2003]{.text-red} -> <sample...
        html = re.sub(
            r":sample\[([^\]]+?)\]\{\.text-(green|red|yellow)\}",
            r"<sample data-toggle='tooltip' title='Click to highlight in the report' class='text-\2'>\1</sample>",
            html,
        )
        return html

    # def format_text(self) -> str:
    #     """
    #     Format to markdown to display in Seqera AI
    #     """
    #     summary = deanonymize_sample_names(self.summary)
    #     detailed = deanonymize_sample_names(self.detailed_analysis) if self.detailed_analysis else None
    #     return f"## Analysis\n{summary}" + (f"\n\n{detailed}" if detailed else "")


class InterpretationResponse(BaseModel):
    interpretation: InterpretationOutput
    model: str
    thread_id: Optional[str] = None


ResponseT = TypeVar("ResponseT")


class Client:
    def __init__(self, api_key: str):
        self.name: str
        self.title: str
        self.model: str
        self.api_key: str = api_key

    def _query(self, prompt: str):
        raise NotImplementedError

    def interpret_report_short(self, report_content: str) -> InterpretationResponse:
        response = self._query(PROMPT_SHORT + "\n\n" + report_content)

        return InterpretationResponse(
            interpretation=InterpretationOutput(summary=response.content),
            model=response.model,
        )

    def interpret_report_full(self, report_content: str) -> InterpretationResponse:
        response = self._query(PROMPT_FULL + "\n\n" + report_content)

        try:
            output = yaml.safe_load(response.content)
            return InterpretationResponse(
                interpretation=InterpretationOutput(**output),
                model=response.model,
            )
        except Exception:
            return InterpretationResponse(
                interpretation=InterpretationOutput(summary=response.content),
                model=response.model,
            )

    def max_tokens(self) -> int:
        raise NotImplementedError

    FALLBACK_LOG_PRINTED = False

    def n_tokens(self, text: str) -> int:
        """
        Estimate token count. Using here tiktoken library. It's an OpenAI tokenizer, so it's not ideal for Anthropic,
        but better than nothing. Anthropic's tokenizer is only available through API and counts towards the API quota :'(
        """
        try:
            import tiktoken

            model = self.model if self.name == "openai" else "gpt-4o"
            encoding = tiktoken.encoding_for_model(model)
            return len(encoding.encode(text))
        except Exception as e:
            if not self.FALLBACK_LOG_PRINTED:
                logger.warning(f"Fail to call tiktoken, falling back to rough token estimation. Error: {e}")
                self.FALLBACK_LOG_PRINTED = True
            return int(len(text) / 1.5)

    def _request_with_error_handling_and_retries(
        self, url: str, headers: Dict[str, Any], body: Dict[str, Any], retries: int = 1
    ) -> Dict[str, Any]:
        """Make a request with retries and exponential backoff.

        Args:
            send_request: Callable that makes the actual request
            retries: Number of retries (0 means no retries)
        """
        import time

        attempt = 0
        while True:
            try:
                if config.verbose:
                    response = requests.post(url, headers=headers, json=body)
                else:
                    response = run_with_spinner(
                        "ai",
                        f"Summarizing report with {self.title}...",
                        lambda: requests.post(url, headers=headers, json=body),
                    )
                response.raise_for_status()
                return response.json()

            except Exception as e:
                attempt += 1
                if attempt > retries:
                    logger.error(f"Failed to get a response from {self.title}. Error: {e}")
                    if config.verbose:
                        # redact content in messages
                        for m in body["messages"]:
                            if isinstance(m["content"], list):
                                for chunk in m["content"]:
                                    chunk["text"] = "<redacted>"
                            elif isinstance(m["content"], str):
                                m["content"] = "<redacted>"
                        logger.debug(f"Request body: {json.dumps(body, indent=2)}")
                    raise

                # Calculate backoff time - start at 1s and double each retry
                backoff = 2 ** (attempt - 1)
                logger.warning(
                    f"Failed to get a response from {self.title} (attempt {attempt}/{retries}). Error: {e}. "
                    f"Retrying in {backoff}s..."
                )
                time.sleep(backoff)


class OpenAiClient(Client):
    def __init__(self, api_key: str, endpoint: Optional[str] = None):
        super().__init__(api_key)

        if endpoint:
            self.endpoint = endpoint
            if not config.ai_model:
                raise ValueError("Custom OpenAI endpoint is set, but no model is provided. Please set config.ai_model")
            self.model = config.ai_model
            self.name = "custom"
            self.title = endpoint
        else:
            self.endpoint = "https://api.openai.com/v1/chat/completions"
            self.model = config.ai_model or "gpt-4o"
            self.name = "openai"
            self.title = "OpenAI"

    def max_tokens(self) -> int:
        return config.ai_custom_context_window or 128000

    class ApiResponse(NamedTuple):
        content: str
        model: str

    def _query(self, prompt: str, extra_options: Optional[Dict[str, Any]] = None) -> ApiResponse:
        body: Dict[str, Any] = {
            "temperature": 0.0,
        }
        if config.ai_extra_query_options:
            body.update(config.ai_extra_query_options)
        if extra_options:
            body.update(extra_options)
        body.update(
            {
                "model": self.model,
                "messages": [
                    {"role": "user", "content": prompt},
                ],
            }
        )
        response = self._request_with_error_handling_and_retries(
            self.endpoint,
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {self.api_key}",
            },
            body=body,
        )
        return OpenAiClient.ApiResponse(
            content=response["choices"][0]["message"]["content"],
            model=response.get("model", self.model),
        )


class AnthropicClient(Client):
    def __init__(self, api_key: str):
        super().__init__(api_key)
        self.model = (
            config.ai_model if config.ai_model and config.ai_model.startswith("claude") else "claude-3-5-sonnet-latest"
        )
        self.name = "anthropic"
        self.title = "Anthropic"

    def max_tokens(self) -> int:
        return 200000

    class ApiResponse(NamedTuple):
        content: str
        model: str

    def _query(self, prompt: str) -> ApiResponse:
        response = self._request_with_error_handling_and_retries(
            "https://api.anthropic.com/v1/messages",
            headers={
                "Content-Type": "application/json",
                "x-api-key": self.api_key,
                "anthropic-version": "2023-06-01",
            },
            body={
                "model": self.model,
                "max_tokens": 4096,
                "messages": [
                    {"role": "user", "content": prompt},
                ],
                "temperature": 0.0,
            },
        )
        return AnthropicClient.ApiResponse(
            content=response["content"][0]["text"],
            model=response.get("model", self.model),
        )


class SeqeraClient(Client):
    def __init__(self, api_key: str):
        super().__init__(api_key)
        self.name = "seqera"
        self.title = "Seqera AI"
        creation_date = report.creation_date.strftime("%d %b %Y, %H:%M %Z")
        self.chat_title = f"{(config.title + ': ' if config.title else '')}MultiQC report, created on {creation_date}"
        self.tags = ["multiqc", f"multiqc_version:{config.version}"]
        self.model = config.ai_model or "claude-3-5-sonnet-latest"

    def max_tokens(self) -> int:
        return 200000

    def wrap_details(self, prompt) -> str:
        return f"{self.chat_title}\n\n:::details\n\n{prompt}\n\n:::\n\n"

    class ApiResponse(NamedTuple):
        content: Union[str, Dict[str, str]]
        model: str
        thread_id: Optional[str] = None

    def _send_request(
        self, prompt: str, report_content: str, extra_options: Optional[Dict[str, Any]] = None
    ) -> ApiResponse:
        response = self._request_with_error_handling_and_retries(
            f"{config.seqera_api_url}/internal-ai/query",
            headers={"Authorization": f"Bearer {self.api_key}"},
            body={
                "message": self.wrap_details(prompt + "\n\n" + report_content),
                "tags": self.tags,
                "title": self.chat_title,
                **(extra_options or {}),
            },
        )
        return SeqeraClient.ApiResponse(
            content=response["generation"],
            model=response.get("model", self.model),
            thread_id=response.get("thread_id"),
        )

    def interpret_report_short(self, report_content: str) -> InterpretationResponse:
        response = self._send_request(PROMPT_SHORT, report_content, extra_options=None)

        return InterpretationResponse(
            interpretation=InterpretationOutput(summary=str(response.content)),
            model=response.model,
            thread_id=response.thread_id,
        )

    def interpret_report_full(self, report_content: str) -> InterpretationResponse:
        response = self._send_request(
            PROMPT_FULL,
            report_content,
            extra_options={
                "response_schema": {
                    "name": "Interpretation",
                    "description": "Interpretation of a MultiQC report",
                    "input_schema": {
                        "type": "object",
                        "required": ["summary"],
                        "properties": {
                            key: {
                                "type": "string",
                                "description": value.description,
                                **({"default": value.default} if value.default is None else {}),
                            }
                            for key, value in InterpretationOutput.model_fields.items()
                        },
                    },
                },
            },
        )

        try:
            output = InterpretationOutput.model_validate(response.content)
            return InterpretationResponse(
                interpretation=output,
                model=response.model,
                thread_id=response.thread_id,
            )
        except Exception as e:
            logger.error(f"Failed to parse Seqera response as JSON: {e}")
            raise


def get_llm_client() -> Optional[Client]:
    if not config.ai_summary:
        return None

    if config.ai_provider == "seqera":
        if api_key := os.environ.get("SEQERA_ACCESS_TOKEN"):
            logger.debug("Using Seqera access token from $SEQERA_ACCESS_TOKEN environment variable")
        elif api_key := os.environ.get("TOWER_ACCESS_TOKEN"):
            logger.debug("Using Seqera access token from TOWER_ACCESS_TOKEN environment variable")
        else:
            logger.error(
                "config.ai_summary is set to true, and config.ai_provider is set to 'seqera', "
                "but Seqera access token is not set. "
                "Please set the SEQERA_ACCESS_TOKEN / TOWER_ACCESS_TOKEN environment variable "
                "or change config.ai_provider"
            )
            return None
        return SeqeraClient(api_key)

    elif config.ai_provider == "anthropic":
        api_key = os.environ.get("ANTHROPIC_API_KEY")
        if not api_key:
            logger.error(
                "config.ai_summary is set to true, and config.ai_provider is set to 'anthropic', but Anthropic API "
                "key not set. Please set the ANTHROPIC_API_KEY environment variable, or change config.ai_provider"
            )
            return None
        logger.debug("Using Anthropic API key from ANTHROPIC_API_KEY environment variable")
        try:
            return AnthropicClient(api_key)
        except ModuleNotFoundError:
            raise ModuleNotFoundError(
                'AI summary requested through `config.ai_summary`, but required dependencies are not installed. Install them with `pip install "multiqc[anthropic]"`'
            )

    elif config.ai_provider == "openai":
        api_key = os.environ.get("OPENAI_API_KEY")
        if not api_key:
            logger.error(
                "config.ai_summary is set to true, and config.ai_provider is set to 'openai', but OpenAI API "
                "key not set. Please set the OPENAI_API_KEY environment variable, or change config.ai_provider"
            )
            return None
        logger.debug("Using OpenAI API key from OPENAI_API_KEY environment variable")
        try:
            return OpenAiClient(api_key)
        except ModuleNotFoundError:
            raise ModuleNotFoundError(
                'AI summary requested through `config.ai_summary`, but required dependencies are not installed. Install them with `pip install "multiqc[openai]"`'
            )

    elif config.ai_provider == "custom":
        api_key = os.environ.get("OPENAI_API_KEY")
        if not api_key:
            logger.error(
                "config.ai_summary is set to true, and config.ai_provider is set to 'custom', but OpenAI API "
                "key not set. Please set the OPENAI_API_KEY environment variable, or change config.ai_provider"
            )
            return None
        if not config.ai_model:
            raise ValueError(
                "config.ai_summary is set to true, and config.ai_provider is set to 'custom', but no config.ai_model is provided. Please set config.ai_model"
            )
        if not config.ai_custom_endpoint:
            raise ValueError(
                "config.ai_summary is set to true, and config.ai_provider is set to 'custom', but no config.ai_custom_endpoint is provided. Please set config.ai_custom_endpoint"
            )
        logger.debug(
            f"Using API key from the OPENAI_API_KEY environment variable to use with a custom endpoint {config.ai_custom_endpoint}"
        )
        return OpenAiClient(api_key=api_key, endpoint=config.ai_custom_endpoint)
    else:
        avail_providers = config_schema.AiProviderLiteral.__args__  # type: ignore
        msg = f'Unknown AI provider "{config.ai_provider}". Please set config.ai_provider to one of the following: [{", ".join(avail_providers)}]'
        if config.strict:
            raise RuntimeError(msg)
        logger.error(msg + ". Skipping AI summary")
        return None


def _strip_html(text: str) -> str:
    return text.replace("<p>", "").replace("</p>", "")


class AiToolMetadata(BaseModel):
    name: str
    info: Optional[str] = None
    href: Optional[str] = None
    comment: Optional[str] = None


class AiSectionMetadata(BaseModel):
    name: str
    module_anchor: Anchor
    description: Optional[str] = None
    comment: Optional[str] = None
    helptext: Optional[str] = None
    plot_anchor: Optional[Anchor] = None
    content: Optional[str] = None
    content_before_plot: Optional[str] = None


class AiReportMetadata(BaseModel):
    tools: Dict[Anchor, AiToolMetadata]
    sections: Dict[Anchor, AiSectionMetadata]


def ai_section_metadata() -> AiReportMetadata:
    return AiReportMetadata(
        tools={
            mod.anchor: AiToolMetadata(
                name=mod.name,
                info=mod.info,
                href=", ".join(mod.href),
                comment=mod.comment,
            )
            for mod in report.modules
            if not mod.hidden and mod.name != "Software Versions"
        },
        sections={
            section.anchor: AiSectionMetadata(
                name=section.name,
                description=section.description,
                comment=section.comment,
                helptext=section.helptext,
                plot_anchor=section.plot_anchor,
                module_anchor=section.module_anchor,
                content=section.content,
                content_before_plot=section.content_before_plot,
            )
            for mod in report.modules
            for section in mod.sections
            if not mod.hidden and mod.name != "Software Versions"
        },
    )


def create_pseudonym_map(sample_names: List[SampleName]) -> Dict[str, str]:
    """
    Find all sample names in the report and replace them with anonymised names
    """
    # Create anonymised names map and reverse map
    ai_pseudonym_map = {}
    for i, name in enumerate(sample_names):
        ai_pseudonym_map[str(name)] = f"SAMPLE_{i + 1}"
    return ai_pseudonym_map


def deanonymize_sample_names(text: str) -> str:
    """
    Convert pseudonyms back to original sample names in the text.
    Only applies when config.ai_anonymize_samples is True.
    """
    if not config.ai_anonymize_samples or not report.ai_pseudonym_map:
        return text

    # Create reverse mapping from pseudonym to original name
    reverse_map = {v: k for k, v in report.ai_pseudonym_map.items()}

    # Replace pseudonyms with original names, being careful to only replace whole words
    # and preserve the directive syntax
    for pseudonym, original in reverse_map.items():
        # Look for pseudonym in :sample[SAMPLE_1]{.text-*} directives
        text = re.sub(f":sample\\[{re.escape(pseudonym)}\\](\\{{[^}}]+\\}})", f":sample[{original}]\\1", text)

        # Look for standalone pseudonyms (not in directives)
        text = re.sub(f"\\b{re.escape(pseudonym)}\\b", str(original), text)

    return text


def build_prompt(client: Client, metadata: AiReportMetadata) -> Tuple[str, bool]:
    system_prompt = PROMPT_FULL if config.ai_summary_full else PROMPT_SHORT

    # Account for system message, plus leave 10% buffer
    max_tokens = client.max_tokens()

    user_prompt: str = ""
    current_n_tokens = client.n_tokens(system_prompt)

    # Details about modules used in the report - include first in the prompt
    tools_context: str = ""
    tools_context += "Tools used in the report:\n\n"
    for i, tool in enumerate(metadata.tools.values()):
        tools_context += f"{i + 1}. {tool.name}\n"
        if tool.info:
            tools_context += f"Description: {tool.info}\n"
        if tool.href:
            tools_context += f"Links: {tool.href}\n"
        if tool.comment:
            tools_context += f"Comment: {tool.comment}\n"
        tools_context += "\n\n"
        tools_context += "\n----------------------\n\n"
    user_prompt += tools_context

    # General stats - also always include, otherwise we don't have anything to summarize
    if general_stats_plot := report.plot_by_id.get(Anchor("general_stats_table")):
        genstats_context = f"""
MultiQC General Statistics (overview of key QC metrics for each sample, across all tools)
{general_stats_plot.format_for_ai_prompt()}
"""
        genstats_n_tokens = client.n_tokens(genstats_context)
        if current_n_tokens + genstats_n_tokens > max_tokens:
            # If it's too long already, try without hidden columns
            genstats_context = f"""
MultiQC General Statistics (overview of key QC metrics for each sample, across all tools)
{general_stats_plot.format_for_ai_prompt(keep_hidden=False)}
"""
            genstats_n_tokens = client.n_tokens(genstats_context)
            if current_n_tokens + genstats_n_tokens > max_tokens:
                logger.debug(
                    f"General stats (almost) exceeds the {client.title}'s context window ({current_n_tokens} + "
                    f"{genstats_n_tokens} tokens, max: {client.max_tokens()} tokens). "
                    "AI summary will not be generated. Try hiding some columns in the general stats table "
                    "(see https://docs.seqera.io/multiqc/reports/customisation#hiding-columns) to reduce the context. "
                    "You can also open the HTML report in the browser, hide columns or samples dynamically, and request "
                    "the AI summary dynamically, or copy the prompt into clipboard and use it with extrenal services."
                )
                return user_prompt + genstats_context, True
        user_prompt += genstats_context
        current_n_tokens += genstats_n_tokens

    user_prompt = re.sub(r"\n\n\n", "\n\n", user_prompt)  # strip triple newlines

    # Build sections context and use it unless it's too long for the LLM context
    sec_context: str = ""
    for section in metadata.sections.values():
        tool = metadata.tools[section.module_anchor]
        sec_context += "\n----------------------\n\n"
        sec_context += f"Tool: {tool.name}\n"
        sec_context += f"Section: {section.name}\n"
        if section.description:
            sec_context += f"Section description: {_strip_html(section.description)}\n"
        if section.comment:
            sec_context += f"Section comment: {_strip_html(section.comment)}\n"
        if section.helptext:
            sec_context += f"Section help text: {_strip_html(section.helptext)}\n"

        if section.content_before_plot:
            sec_context += report.anonymize_sample_name(section.content_before_plot) + "\n\n"
        if section.content:
            sec_context += report.anonymize_sample_name(section.content) + "\n\n"

        if section.plot_anchor and section.plot_anchor in report.plot_by_id:
            plot = report.plot_by_id[section.plot_anchor]
            if plot_content := plot.format_for_ai_prompt(keep_hidden=True):
                if plot.pconfig.title:
                    sec_context += f"Title: {plot.pconfig.title}\n"
                sec_context += "\n" + plot_content

        # Check if adding this section would exceed the limit
        # Using rough estimate of 4 chars per token
        sec_n_tokens = client.n_tokens(sec_context)
        if current_n_tokens + sec_n_tokens > max_tokens:
            logger.debug(
                f"Including only General Statistics table to fit within {client.title}'s context window ({client.max_tokens()} tokens). "
                f"Tokens estimate: {current_n_tokens}, with sections: {current_n_tokens + sec_n_tokens}"
            )
            return user_prompt, False

    user_prompt += sec_context
    user_prompt = re.sub(r"\n\n\n", "\n\n", user_prompt)  # strip triple newlines
    return user_prompt, False


def _save_prompt_to_file(prompt: str):
    """Save content to file for debugging"""
    path = report.data_tmp_dir() / "multiqc_ai_prompt.txt"
    system_prompt = PROMPT_FULL if config.ai_summary_full else PROMPT_SHORT
    path.write_text(f"{system_prompt}\n\n----------------------\n\n{prompt}")
    logger.debug(f"Saved AI prompt to {path.parent.name}/{path.name}")


def add_ai_summary_to_report():
    metadata: AiReportMetadata = ai_section_metadata()
    # Set data for JS runtime
    report.ai_report_metadata_base64 = base64.b64encode(metadata.model_dump_json().encode()).decode()
    report.ai_extra_query_options_base64 = base64.b64encode(
        json.dumps(config.ai_extra_query_options or {}).encode()
    ).decode()
    # Create and save the map for format_dataset_for_ai_prompt or JS runtime
    report.ai_pseudonym_map = create_pseudonym_map(report.sample_names)
    # Save for the JS runtime. We want to do it regardless of config.ai_anonymize_samples,
    # because JS runtime might need it even if Python runtime doesn't
    report.ai_pseudonym_map_base64 = base64.b64encode(json.dumps(report.ai_pseudonym_map).encode()).decode()

    # Now that everything for JS runtime is set, we only continue if static summaries are requested
    if not config.ai_summary:
        # Not generating report in Python, leaving for JS runtime
        return

    if not (client := get_llm_client()):
        return

    report.ai_provider_id = client.name
    report.ai_provider_title = client.title
    report.ai_model = client.model

    prompt, exceeded_context_window = build_prompt(client, metadata)

    _save_prompt_to_file(prompt)

    if exceeded_context_window:
        return

    response: InterpretationResponse
    try:
        if config.ai_summary_full:
            if config.development and os.environ.get("MQC_STUB_AI_RESPONSE"):
                response = InterpretationResponse(
                    interpretation=InterpretationOutput(
                        summary=_EXAMPLE_SUMMARY_FOR_FULL,
                        detailed_analysis=_EXAMPLE_DETAILED_SUMMARY,
                    ),
                    model="test-model",
                    thread_id="68bcead8-1bea-4b75-84d1-fc2ae6afed51" if client.name == "seqera" else None,
                )
            else:
                response = client.interpret_report_full(prompt)
        else:
            if config.development and os.environ.get("MQC_STUB_AI_RESPONSE"):
                response = InterpretationResponse(
                    interpretation=InterpretationOutput(
                        summary="- All samples show :span[good quality metrics]{.text-green} with consistent CpG methylation (:span[75.7-77.0%]{.text-green}), alignment rates (:span[76-86%]{.text-green}), and balanced strand distribution (:span[~50/50]{.text-green})\n- :sample[2wk]{.text-yellow} samples show slightly higher duplication (:span[11-15%]{.text-yellow}) and trimming rates (:span[13-23%]{.text-yellow}) compared to :sample[1wk]{.text-green} samples (:span[6-9%]{.text-green} duplication, :span[2-3%]{.text-green} trimming)",
                    ),
                    model="test-model",
                    thread_id="68bcead8-1bea-4b75-84d1-fc2ae6afed51" if client.name == "seqera" else None,
                )
            else:
                response = client.interpret_report_short(prompt)
    except Exception as e:
        logger.error(f"Failed to interpret report with {client.title}: {e}")
        if config.strict:
            raise
        return None

    if not response.interpretation:
        return None

    if response.model:
        report.ai_model_resolved = response.model

    if response.thread_id:
        report.ai_thread_id = response.thread_id

    interpretation: InterpretationOutput = response.interpretation
    report.ai_global_summary = interpretation.markdown_to_html(interpretation.summary)

    if config.ai_summary_full and interpretation.detailed_analysis:
        report.ai_global_detailed_analysis = interpretation.markdown_to_html(interpretation.detailed_analysis)

    logger.info(f"Summarised report with {report.ai_provider_title}")
