from __future__ import annotations

import hashlib
import json
import logging
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from ..models import SupportTicketNote
from ..providers.vertex_ai_provider import VertexAIProvider

TRIAGE_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "tldr": {"type": "string"},
        "hypotheses": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "cause": {"type": "string"},
                    "confidence": {"type": "number", "minimum": 0, "maximum": 1},
                    "evidence": {"type": "array", "items": {"type": "string"}},
                },
                "required": ["cause", "confidence", "evidence"],
            },
        },
        "next_actions": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "action": {"type": "string"},
                    "where": {"type": "string"},
                },
                "required": ["action", "where"],
            },
        },
        "questions_for_user": {"type": "array", "items": {"type": "string"}},
        "customer_reply_draft": {"type": "string"},
        "knowledge_sources": {"type": "array", "items": {"type": "string"}},
    },
    "required": [
        "tldr",
        "hypotheses",
        "next_actions",
        "questions_for_user",
        "customer_reply_draft",
    ],
}

KNOWLEDGE_MATCH_RULES: Tuple[Dict[str, Any], ...] = (
    {
        "path": "docs/runbooks/beta_testers.md",
        "categories": ("gremlin", "google-sheets", "gsheets"),
        "keywords": (
            "google sheets",
            "gremlin gs connector",
            "access_denied",
            "oauth",
            "verification",
            "test user",
            "403",
            "beta",
        ),
    },
    {
        "path": "docs/runbooks/hubspot_oauth_support.md",
        "categories": ("hubspot",),
        "keywords": (
            "hubspot",
            "oauth",
            "invalid_grant",
            "unauthorized",
            "refresh token",
            "connect",
            "callback",
        ),
    },
    {
        "path": "docs/runbooks/gmail_support_gcp.md",
        "categories": ("gmail", "email"),
        "keywords": (
            "gmail",
            "inbound",
            "thread",
            "reply",
            "pubsub",
            "watch",
            "history",
            "sync",
        ),
    },
)

TRIAGE_PROMPT = """You are a senior support engineer triaging a ticket for FoundryMatch, a B2B data matching and enrichment SaaS.

## Ticket
- ID: {public_id}
- Summary: {summary}
- Description: {description}
- Category: {category}
- Severity: {severity}
- Created: {created_at}
- Mode: {mode}

## Customer Context
- Account: {account_name}
- Tier: {tier}
- Usage: {rows_used}/{rows_limit} rows ({usage_pct}%)
- Credits: {credits_used}/{credits_limit}

## Recent Jobs (last 5)
{jobs_summary}

## Debug Context (context_json)
{context_json_summary}

## Product Knowledge (internal runbooks)
{product_knowledge}

## Recent Telemetry Trace
Window: {trace_from} to {trace_to}
Total Events: {total_events} | Failures: {failures} | Retry Count: {retry_count}

Top Failure: {top_failure}
Last Successful Action: {last_successful_action}
Likely Issue Bucket: {likely_bucket}

Top Actions: {top_actions_by_count}
Top Failures: {top_failures_by_count}

Open Logs: {open_logs_link}

Recent Events (last 10-15):
{event_trace}

## IMPORTANT RULES
1. CITE EVIDENCE: Every hypothesis must reference specific fields (e.g., "context_json.activity_log[3]", "usage.credits_used")
2. CONFIDENCE: Be honest about confidence. Low confidence if evidence is missing.
3. UNKNOWN: If you cannot determine something, say "Unknown - ask user" rather than guessing.
4. ACTIONABLE: Next actions should reference specific admin UI locations.
5. MODE: If mode is reply_only, focus on drafting the customer reply but still fill the full schema.
6. PROFESSIONAL: Customer reply should be empathetic, clear, and offer concrete solutions.
7. EXECUTION CLAIMS: Do not claim an action is already completed (for example "I added you") unless explicitly confirmed in ticket/context data.
8. RUNBOOK SOURCES: If product knowledge was used, cite sources as runbook:<path> in evidence and include those paths in knowledge_sources.

## Task
Analyze this ticket and return a JSON object with:
- tldr: One clear sentence summarizing the issue
- hypotheses: Ranked list of likely causes with confidence (0-1) and evidence citations
- next_actions: What support should do next and where in the admin UI
- questions_for_user: Questions to ask if info is missing
- customer_reply_draft: Professional reply ready to send
- knowledge_sources: List of runbook file paths used (empty list if none)"""


class SupportTriageService:
    def __init__(self, model: Optional[str] = None, cache_minutes: Optional[int] = None):
        # Default to Gemini 3 Flash - fast and reliable for structured JSON
        resolved_model = model or os.getenv(
            "VERTEX_TRIAGE_MODEL", "gemini-3-flash-preview"
        )
        self.provider = VertexAIProvider(model=resolved_model)
        self.fallback_model = os.getenv(
            "SUPPORT_TRIAGE_FALLBACK_MODEL", "gemini-3-flash-preview"
        )
        self.cache_minutes = int(
            cache_minutes or os.getenv("SUPPORT_TRIAGE_CACHE_MINUTES", "30")
        )
        self.context_limit = int(os.getenv("SUPPORT_TRIAGE_CONTEXT_MAX_CHARS", "3000"))
        self.context_limit_retry = int(
            os.getenv("SUPPORT_TRIAGE_CONTEXT_MAX_CHARS_RETRY", "1500")
        )
        self.max_output_tokens = int(
            os.getenv("SUPPORT_TRIAGE_MAX_OUTPUT_TOKENS", "2048")
        )
        self.max_output_tokens_retry = int(
            os.getenv("SUPPORT_TRIAGE_MAX_OUTPUT_TOKENS_RETRY", "1024")
        )
        self.timeout_s = int(os.getenv("SUPPORT_TRIAGE_TIMEOUT_S", "60"))
        self.enable_knowledge = (
            os.getenv("SUPPORT_TRIAGE_ENABLE_KNOWLEDGE", "1").strip().lower()
            not in {"0", "false", "no"}
        )
        self.knowledge_max_docs = int(
            os.getenv("SUPPORT_TRIAGE_KNOWLEDGE_MAX_DOCS", "2")
        )
        self.knowledge_max_chars = int(
            os.getenv("SUPPORT_TRIAGE_KNOWLEDGE_MAX_CHARS", "2400")
        )
        self.knowledge_max_chars_per_doc = int(
            os.getenv("SUPPORT_TRIAGE_KNOWLEDGE_MAX_CHARS_PER_DOC", "1200")
        )
        self.repo_root = Path(__file__).resolve().parents[4]
        self._knowledge_cache: Dict[str, str] = {}
        self._log = logging.getLogger(__name__)

    async def run_triage(
        self,
        *,
        db: AsyncSession,
        ticket: Any,
        account: Any,
        usage: Any,
        recent_jobs: List[Dict[str, Any]],
        mode: str = "full",
        force: bool = False,
    ) -> Tuple[Dict[str, Any], str, bool]:
        self._log.info(
            "run_triage called: ticket=%s, mode=%s, force=%s",
            ticket.public_id, mode, force,
        )
        context = self._build_triage_context(
            ticket,
            account,
            usage,
            recent_jobs,
            mode,
            context_limit=self.context_limit,
        )
        context_hash = hashlib.md5(
            json.dumps(context, sort_keys=True).encode("utf-8")
        ).hexdigest()

        if not force and ticket.ai_triage_hash == context_hash:
            if ticket.ai_triage_last_run_at:
                last_run = ticket.ai_triage_last_run_at
                if last_run.tzinfo is None:
                    last_run = last_run.replace(tzinfo=timezone.utc)
                age_minutes = (
                    datetime.now(timezone.utc) - last_run
                ).total_seconds() / 60
                if age_minutes < self.cache_minutes:
                    cached = await self._get_cached_triage(db, ticket.id)
                    if cached is not None:
                        self._log.info("Returning cached triage (age=%.1fm)", age_minutes)
                        return cached, context_hash, True

        self._log.info("Calling Vertex AI for triage (force=%s)", force)
        prompt = self._build_prompt(context)
        thinking_level = "LOW" if mode == "reply_only" else "MEDIUM"
        max_tokens = (
            min(self.max_output_tokens, 1024)
            if mode == "reply_only"
            else self.max_output_tokens
        )
        raw_text = None
        try:
            result = self.provider.generate_json(
                prompt=prompt,
                schema=TRIAGE_SCHEMA,
                thinking_level=thinking_level,
                max_output_tokens=max_tokens,
                timeout_s=self.timeout_s,
            )
            self._log.info("Vertex AI returned, parsing result...")
            value, raw_text = self._parse_result(result)
            if value is not None:
                self._log.info("Parsed successfully, tldr=%s", (value.get("tldr") or "")[:80])
                value = self._ensure_customer_reply(value, ticket)
                value = self._attach_knowledge_sources(value, context)
                return value, context_hash, False
            self._log.warning("Parse returned None, will retry. raw_text=%s", (raw_text or "")[:120])
        except TimeoutError:
            self._log.warning("AI triage timed out; retrying with smaller context")
        except Exception as exc:
            self._log.warning("AI triage parse failed; retrying once: %s", exc)

        retry_context = self._build_triage_context(
            ticket,
            account,
            usage,
            recent_jobs,
            mode,
            context_limit=self.context_limit_retry,
        )
        retry_prompt = self._build_prompt(retry_context)
        retry_prompt += (
            "\n\n## OUTPUT RULES (CRITICAL)\n"
            "1. Return ONLY a single valid JSON object - no duplicates, no repetition\n"
            "2. Each field appears EXACTLY ONCE\n"
            "3. No prose, no markdown, no code fences\n"
            "4. customer_reply_draft must be a complete, professional email\n"
            "5. Keep the response concise to avoid truncation"
        )
        fallback_model = self.fallback_model
        retry_provider = self.provider
        if fallback_model:
            current_model = getattr(self.provider, "_model_name", "") or ""
            if fallback_model.lower() != current_model.lower():
                retry_provider = VertexAIProvider(model=fallback_model)

        self._log.info("Retry: calling Vertex AI with fallback model")
        result = retry_provider.generate_json(
            prompt=retry_prompt,
            schema=TRIAGE_SCHEMA,
            thinking_level="MINIMAL",
            max_output_tokens=self.max_output_tokens_retry,
            timeout_s=self.timeout_s,
        )
        self._log.info("Retry: Vertex AI returned, parsing...")
        value, raw_text = self._parse_result(result)
        if value is None:
            self._log.warning("Retry parse failed, using fallback. raw_text=%s", (raw_text or "")[:120])
            value = self._fallback_value(raw_text)
        else:
            self._log.info("Retry parsed successfully, tldr=%s", (value.get("tldr") or "")[:80])
        value = self._ensure_customer_reply(value, ticket)
        value = self._attach_knowledge_sources(value, retry_context)
        return value, context_hash, False

    def _build_triage_context(
        self,
        ticket: Any,
        account: Any,
        usage: Any,
        recent_jobs: List[Dict[str, Any]],
        mode: str,
        context_limit: Optional[int] = None,
    ) -> Dict[str, Any]:
        limit = context_limit if context_limit is not None else self.context_limit
        telemetry = self._build_telemetry_context(ticket.context_json or {})
        product_knowledge, knowledge_sources = self._build_product_knowledge(ticket)
        return {
            "ticket": {
                "public_id": ticket.public_id,
                "summary": ticket.summary,
                "description": ticket.description or "No description",
                "category": ticket.category or "Uncategorized",
                "severity": ticket.severity.value if ticket.severity else "Unknown",
                "created_at": ticket.created_at.isoformat()
                if ticket.created_at
                else "Unknown",
                "mode": mode,
            },
            "customer": {
                "account_name": account.name if account else "Unknown",
                "tier": usage.tier if usage else "Unknown",
                "rows_used": usage.monthly_rows_used if usage else 0,
                "rows_limit": usage.monthly_match_entitlement if usage else 0,
                "credits_used": usage.enrichment_credits_used if usage else 0,
                "credits_limit": usage.monthly_enrichment_entitlement if usage else 0,
            },
            "recent_jobs": recent_jobs or [],
            "context_json_summary": json.dumps(ticket.context_json or {}, indent=2)[
                :limit
            ],
            "telemetry": telemetry,
            "product_knowledge": product_knowledge,
            "knowledge_sources": knowledge_sources,
        }

    def _build_prompt(self, context: Dict[str, Any]) -> str:
        usage = context["customer"]
        telemetry = context.get("telemetry") or {}
        rows_limit = usage["rows_limit"] or 0
        usage_pct = (
            round(usage["rows_used"] / max(rows_limit, 1) * 100) if rows_limit else 0
        )
        jobs_summary = "None"
        if context["recent_jobs"]:
            jobs_summary = "\n".join(
                [
                    f"- {j['id']}: {j.get('status')} ({j.get('job_type')}) at {j.get('created_at')}"
                    for j in context["recent_jobs"]
                ]
            )

        return TRIAGE_PROMPT.format(
            **context["ticket"],
            account_name=usage["account_name"],
            tier=usage["tier"],
            rows_used=usage["rows_used"],
            rows_limit=usage["rows_limit"],
            usage_pct=usage_pct,
            credits_used=usage["credits_used"],
            credits_limit=usage["credits_limit"],
            jobs_summary=jobs_summary,
            context_json_summary=context["context_json_summary"],
            product_knowledge=context.get("product_knowledge", "None matched."),
            trace_from=telemetry.get("trace_from", "unknown"),
            trace_to=telemetry.get("trace_to", "unknown"),
            total_events=telemetry.get("total_events", 0),
            failures=telemetry.get("failures", 0),
            retry_count=telemetry.get("retry_count", 0),
            top_failure=telemetry.get("top_failure", "None"),
            last_successful_action=telemetry.get("last_successful_action", "None"),
            likely_bucket=telemetry.get("likely_bucket", "unknown"),
            top_actions_by_count=telemetry.get("top_actions_by_count", "None"),
            top_failures_by_count=telemetry.get("top_failures_by_count", "None"),
            open_logs_link=telemetry.get("open_logs_link", "n/a"),
            event_trace=telemetry.get("event_trace", "None"),
        )

    def _build_telemetry_context(self, context_json: Dict[str, Any]) -> Dict[str, Any]:
        bundle = (context_json or {}).get("telemetry_bundle") or {}
        if not isinstance(bundle, dict) or bundle.get("error"):
            return {
                "trace_from": "unknown",
                "trace_to": "unknown",
                "total_events": 0,
                "failures": 0,
                "retry_count": 0,
                "top_failure": "None",
                "last_successful_action": "None",
                "likely_bucket": "unknown",
                "top_actions_by_count": "None",
                "top_failures_by_count": "None",
                "open_logs_link": "n/a",
                "event_trace": "None",
            }

        trace_window = bundle.get("trace_window") or {}
        summary = bundle.get("summary") or {}
        top_failure = bundle.get("top_failure") or {}
        events = bundle.get("events") or []

        return {
            "trace_from": trace_window.get("from") or "unknown",
            "trace_to": trace_window.get("to") or "unknown",
            "total_events": summary.get("total_events") or 0,
            "failures": summary.get("failures") or 0,
            "retry_count": summary.get("retry_count") or 0,
            "top_failure": self._format_top_failure(top_failure),
            "last_successful_action": bundle.get("last_successful_action") or "None",
            "likely_bucket": summary.get("likely_bucket") or "unknown",
            "top_actions_by_count": self._format_top_actions(
                summary.get("top_actions_by_count") or []
            ),
            "top_failures_by_count": self._format_top_failures(
                summary.get("top_failures_by_count") or []
            ),
            "open_logs_link": (bundle.get("links") or {}).get("open_logs") or "n/a",
            "event_trace": self._format_event_trace(events),
        }

    @staticmethod
    def _format_top_failure(top_failure: Dict[str, Any]) -> str:
        if not top_failure:
            return "None"
        action = top_failure.get("action") or "unknown"
        error_code = top_failure.get("error_code") or "null"
        count = top_failure.get("count") or 0
        last_seen = top_failure.get("last_seen") or "unknown"
        return f"{action} | {error_code} | count={count} | last_seen={last_seen}"

    @staticmethod
    def _format_top_actions(items: List[Dict[str, Any]]) -> str:
        if not items:
            return "None"
        parts = []
        for item in items:
            action = item.get("action") or "unknown"
            count = item.get("count") or 0
            parts.append(f"{action} ({count})")
        return ", ".join(parts)

    @staticmethod
    def _format_top_failures(items: List[Dict[str, Any]]) -> str:
        if not items:
            return "None"
        parts = []
        for item in items:
            action = item.get("action") or "unknown"
            error_code = item.get("error_code") or "null"
            count = item.get("count") or 0
            parts.append(f"{action}:{error_code} ({count})")
        return ", ".join(parts)

    def _format_event_trace(self, events: List[Dict[str, Any]]) -> str:
        if not events:
            return "None"

        rows = []
        for event in events[:15]:
            ts = event.get("ts") or ""
            action = event.get("action") or ""
            status = (event.get("status") or "").upper()
            error_code = event.get("error_code")
            error_code_display = error_code if error_code is not None else "null"
            duration = event.get("duration_ms")
            duration_display = str(duration) if duration is not None else "null"
            bucket = self._derive_bucket(error_code_display, status)
            rows.append(
                {
                    "ts": ts,
                    "action": action,
                    "status": status,
                    "error_code": error_code_display,
                    "duration_ms": duration_display,
                    "bucket": bucket,
                }
            )

        headers = ["ts", "action", "status", "error_code", "duration_ms", "bucket"]
        widths = {key: len(key) for key in headers}
        for row in rows:
            for key in headers:
                widths[key] = max(widths[key], len(str(row.get(key, ""))))

        header_line = " | ".join(
            [f"{key:<{widths[key]}}" for key in headers]
        )
        divider = "-+-".join(["-" * widths[key] for key in headers])

        lines = [header_line, divider]
        for row in rows:
            line = " | ".join(
                [f"{str(row.get(key, '')):<{widths[key]}}" for key in headers]
            )
            lines.append(line)

        return "\n".join(lines)

    @staticmethod
    def _derive_bucket(error_code: str, status: str) -> str:
        code = (error_code or "").upper()
        if "AUTH" in code or "TOKEN" in code or "OAUTH" in code:
            return "auth"
        if "VALIDATION" in code or "INVALID" in code or "MISSING" in code:
            return "validation"
        if "CONFIG" in code or "MAPPING" in code:
            return "config"
        if "TIMEOUT" in code or "500" in code or "RATE_LIMIT" in code:
            return "backend"
        if status == "FAILED":
            return "unknown"
        return "-"

    async def _get_cached_triage(
        self, db: AsyncSession, ticket_id: str
    ) -> Optional[Dict[str, Any]]:
        stmt = (
            select(SupportTicketNote)
            .where(
                SupportTicketNote.ticket_id == ticket_id,
                SupportTicketNote.author_email == "ai@foundryops.io",
            )
            .order_by(SupportTicketNote.created_at.desc())
            .limit(1)
        )
        note = (await db.execute(stmt)).scalar_one_or_none()
        if not note or not note.content:
            return None
        try:
            return json.loads(note.content)
        except Exception:
            return None

    def _parse_result(
        self, result: Any
    ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
        if not isinstance(result, dict):
            return None, None

        value = result.get("value")
        if isinstance(value, dict):
            if self._looks_like_triage_payload(value):
                return self._normalize_value(value), result.get("raw_text")
            raw_text = value.get("raw_text") if isinstance(value.get("raw_text"), str) else result.get("raw_text")
            if raw_text:
                parsed = self._parse_raw_text(raw_text)
                if parsed is not None:
                    return self._normalize_value(parsed), raw_text
                return None, raw_text
            return None, result.get("raw_text")

        raw_text = result.get("raw_text")
        if raw_text:
            parsed = self._parse_raw_text(raw_text)
            if parsed is not None:
                return self._normalize_value(parsed), raw_text
            return None, raw_text

        return None, None

    @staticmethod
    def _looks_like_triage_payload(payload: Dict[str, Any]) -> bool:
        return any(
            key in payload
            for key in (
                "tldr",
                "hypotheses",
                "next_actions",
                "questions_for_user",
                "customer_reply_draft",
            )
        )

    def _normalize_value(self, payload: Dict[str, Any]) -> Dict[str, Any]:
        return {
            "tldr": payload.get("tldr") or "",
            "hypotheses": payload.get("hypotheses") or [],
            "next_actions": payload.get("next_actions") or [],
            "questions_for_user": payload.get("questions_for_user") or [],
            "customer_reply_draft": payload.get("customer_reply_draft") or "",
            "knowledge_sources": self._normalize_source_list(
                payload.get("knowledge_sources")
            ),
        }

    def _fallback_value(self, raw_text: Optional[str]) -> Dict[str, Any]:
        # Try to extract a usable tldr from malformed output
        tldr = "AI triage could not parse the response. Using fallback reply."
        if raw_text:
            # Look for a tldr value in truncated JSON
            match = re.search(r'"tldr"\s*:\s*"([^"]{10,200})', raw_text)
            if match:
                tldr = match.group(1).rstrip("\\")
        return {
            "tldr": tldr,
            "hypotheses": [],
            "next_actions": [],
            "questions_for_user": [],
            # Leave empty - _ensure_customer_reply will generate a proper response
            "customer_reply_draft": "",
            "knowledge_sources": [],
        }

    def _ensure_customer_reply(
        self, value: Dict[str, Any], ticket: Any
    ) -> Dict[str, Any]:
        draft = value.get("customer_reply_draft") or ""
        if draft.strip():
            value["customer_reply_draft"] = self._sanitize_customer_reply_draft(
                draft, ticket
            )
            return value

        context = ticket.context_json or {}
        sheet_name = context.get("sheet_name") or context.get("sheet") or ""
        job_id = ticket.job_id or context.get("job_id") or None
        tldr = value.get("tldr") or ""
        summary = ticket.summary or ""

        # Build context-aware opening
        if tldr and "AI triage" not in tldr and len(tldr) > 20:
            opening = tldr.rstrip(".") + "."
        elif summary:
            opening = f"We received your report about: {summary}"
        else:
            opening = "We received your support request."

        lines = [
            "Hi,",
            "",
            f"Thanks for reaching out. {opening}",
            "",
        ]

        # Add sheet-specific guidance if we know the sheet
        if sheet_name and sheet_name.startswith("_FM"):
            lines.extend([
                f'It looks like the add-on was running on "{sheet_name}", which is a system/support sheet rather than your data sheet.',
                "",
                "To resolve this:",
                "1. Switch to the sheet that contains your data rows.",
                "2. Make sure the required columns are present (for example, Company, Domain, or Email).",
                "3. Re-run the enrichment job.",
            ])
        else:
            lines.extend([
                "We're looking into this and will follow up shortly.",
            ])

        if job_id:
            lines.extend(["", f"For reference, your job ID is: {job_id}"])

        lines.extend([
            "",
            "If you have any additional details or screenshots, feel free to reply to this email.",
            "",
            "Best,",
            "FoundryMatch Support",
        ])
        value["customer_reply_draft"] = self._sanitize_customer_reply_draft(
            "\n".join(lines), ticket
        )
        self._log.info("AI triage reply fallback applied for ticket %s", ticket.public_id)
        return value

    def _build_product_knowledge(self, ticket: Any) -> Tuple[str, List[str]]:
        if (
            not self.enable_knowledge
            or self.knowledge_max_docs <= 0
            or self.knowledge_max_chars <= 0
        ):
            return "None matched.", []

        category = str(getattr(ticket, "category", "") or "").strip().lower()
        summary = str(getattr(ticket, "summary", "") or "")
        description = str(getattr(ticket, "description", "") or "")
        context_json = getattr(ticket, "context_json", {}) or {}
        try:
            context_blob = json.dumps(context_json, default=str)
        except Exception:
            context_blob = str(context_json)
        signal_text = " ".join([category, summary, description, context_blob]).lower()

        matches: List[Tuple[int, Dict[str, Any], List[str]]] = []
        for rule in KNOWLEDGE_MATCH_RULES:
            categories = [str(item).lower() for item in (rule.get("categories") or [])]
            keywords = [str(item).lower() for item in (rule.get("keywords") or [])]
            category_match = bool(category) and any(item in category for item in categories)
            keyword_hits = [kw for kw in keywords if kw in signal_text]
            # Require at least one keyword hit for category matches to avoid broad false matches.
            if not ((category_match and keyword_hits) or len(keyword_hits) >= 2):
                continue
            score = (3 if category_match else 0) + len(keyword_hits)
            matches.append((score, rule, keyword_hits))

        if not matches:
            return "None matched.", []

        matches.sort(key=lambda item: item[0], reverse=True)
        sections: List[str] = []
        sources: List[str] = []
        remaining_chars = self.knowledge_max_chars

        for _, rule, keyword_hits in matches[: self.knowledge_max_docs]:
            relative_path = str(rule.get("path") or "").strip()
            if not relative_path:
                continue
            content = self._load_knowledge_file(relative_path)
            if not content:
                continue

            per_doc_budget = min(self.knowledge_max_chars_per_doc, remaining_chars)
            if per_doc_budget <= 0:
                break
            excerpt = self._extract_knowledge_excerpt(
                text=content,
                max_chars=per_doc_budget,
                keywords=keyword_hits,
            )
            if not excerpt:
                continue
            sections.append(f"Source: {relative_path}\n{excerpt}")
            sources.append(relative_path)
            remaining_chars -= len(excerpt)
            if remaining_chars <= 0:
                break

        if not sections:
            return "None matched.", []
        return "\n\n".join(sections), sources

    def _load_knowledge_file(self, relative_path: str) -> str:
        cached = self._knowledge_cache.get(relative_path)
        if cached is not None:
            return cached

        absolute_path = self.repo_root / relative_path
        try:
            content = absolute_path.read_text(encoding="utf-8", errors="ignore")
        except FileNotFoundError:
            self._log.warning("Knowledge file not found: %s", absolute_path)
            self._knowledge_cache[relative_path] = ""
            return ""
        except Exception as exc:
            self._log.warning("Failed to read knowledge file %s: %s", absolute_path, exc)
            self._knowledge_cache[relative_path] = ""
            return ""

        self._knowledge_cache[relative_path] = content
        return content

    @staticmethod
    def _extract_knowledge_excerpt(
        *, text: str, max_chars: int, keywords: List[str]
    ) -> str:
        if max_chars <= 0:
            return ""
        lines = [line.rstrip() for line in text.splitlines() if line.strip()]
        compact = "\n".join(lines)
        if not compact:
            return ""
        if len(compact) <= max_chars:
            return compact

        lower_text = compact.lower()
        start = 0
        for keyword in keywords:
            idx = lower_text.find(keyword.lower())
            if idx != -1:
                start = max(0, idx - (max_chars // 3))
                break

        excerpt = compact[start : start + max_chars]
        if start > 0:
            excerpt = "..." + excerpt
        if start + max_chars < len(compact):
            excerpt = excerpt.rstrip() + "..."
        return excerpt

    @staticmethod
    def _normalize_source_list(value: Any) -> List[str]:
        if not isinstance(value, list):
            return []
        normalized: List[str] = []
        for item in value:
            if not isinstance(item, str):
                continue
            entry = item.strip()
            if entry and entry not in normalized:
                normalized.append(entry)
        return normalized

    def _attach_knowledge_sources(
        self, value: Dict[str, Any], context: Dict[str, Any]
    ) -> Dict[str, Any]:
        merged = self._normalize_source_list(value.get("knowledge_sources"))
        for source in self._normalize_source_list(context.get("knowledge_sources")):
            if source not in merged:
                merged.append(source)
        value["knowledge_sources"] = merged
        return value

    @staticmethod
    def _ticket_has_confirmed_action(ticket: Any) -> bool:
        resolution = str(getattr(ticket, "resolution", "") or "").strip()
        if resolution:
            return True

        context_json = getattr(ticket, "context_json", {}) or {}
        if not isinstance(context_json, dict):
            return False
        for key in (
            "actions_taken",
            "admin_actions",
            "completed_actions",
            "changes_applied",
            "resolution_note",
            "status_updates",
            "fix_confirmed",
        ):
            value = context_json.get(key)
            if isinstance(value, str) and value.strip():
                return True
            if isinstance(value, (list, dict)) and len(value) > 0:
                return True
        return False

    def _sanitize_customer_reply_draft(self, draft: str, ticket: Any) -> str:
        if not draft.strip() or self._ticket_has_confirmed_action(ticket):
            return draft

        sanitized = draft
        substitutions = (
            (r"\bI\s+have\s+added\b", "I will add"),
            (r"\bWe've\s+added\b", "We'll add"),
            (r"\bWe\s+have\s+added\b", "We will add"),
            (r"\bI'?ve\s+added\b", "I will add"),
            (r"\bI\s+added\b", "I will add"),
            (r"\bWe\s+added\b", "We will add"),
            (r"\bhas\s+been\s+added\b", "can be added"),
            (r"\bhas\s+been\s+fixed\b", "is being investigated"),
            (r"\bhas\s+been\s+resolved\b", "is being investigated"),
        )
        for pattern, replacement in substitutions:
            sanitized = re.sub(pattern, replacement, sanitized, flags=re.IGNORECASE)
        return sanitized

    def _parse_raw_text(self, raw_text: str) -> Optional[Dict[str, Any]]:
        candidate = self._extract_json_candidate(raw_text)
        if not candidate:
            return None
        try:
            parsed = json.loads(candidate)
            if isinstance(parsed, dict):
                return parsed
        except json.JSONDecodeError:
            repaired = self._repair_json(candidate)
            if repaired:
                try:
                    parsed = json.loads(repaired)
                    if isinstance(parsed, dict):
                        return parsed
                except json.JSONDecodeError:
                    return None
        return None

    @staticmethod
    def _extract_json_candidate(raw_text: str) -> Optional[str]:
        text = raw_text.strip()
        if not text:
            return None
        start = text.find("{")
        end = text.rfind("}")
        if start != -1 and end != -1 and end > start:
            return text[start : end + 1]
        return None

    @staticmethod
    def _repair_json(malformed: str) -> Optional[str]:
        if not malformed or not malformed.strip():
            return None
        text = malformed.strip()
        text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text)
        text = re.sub(r"(?<=[\[{,:])\s*'([^']*)'", r' "\1"', text)
        text = re.sub(r"'([^']*)'(?=\s*[:,\]}])", r'"\1"', text)
        text = re.sub(r"{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r'{"\1":', text)
        text = re.sub(r",\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r', "\1":', text)
        text = re.sub(r",\s*}", "}", text)
        text = re.sub(r",\s*]", "]", text)

        open_braces = text.count("{") - text.count("}")
        open_brackets = text.count("[") - text.count("]")

        if open_braces > 0 or open_brackets > 0:
            text = text.rstrip()
            if text.endswith(","):
                text = text[:-1]
            text += "]" * open_brackets
            text += "}" * open_braces

        try:
            json.loads(text)
            return text
        except json.JSONDecodeError:
            return None
