from __future__ import annotations

import csv
import json
import re
from collections import Counter
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any

NETWORK_TEMPLATE_ID = "network-intelligence"
NETWORK_RENDERER_KEY = "network_intelligence_v1"
NETWORK_TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "network-intelligence-v1.html"

NOISE_LINE_PATTERNS = [
    r"^search$",
    r"^sort by",
    r"^all filters$",
    r"^people$",
    r"^connections$",
    r"^messages?$",
    r"^follow$",
    r"^connect$",
    r"^more$",
    r"^linkedin$",
    r"^premium$",
    r"^try premium",
    r"^\d+(?:st|nd|rd|th)$",
]
NAME_LINE_RE = re.compile(r"^[A-Z][A-Za-z'.-]+(?:\s+[A-Z][A-Za-z'.-]+){0,4}$")
CONNECTED_RECENCY_RE = re.compile(
    r"connected(?:\s+on)?\s+(?:(\d{1,2}/\d{1,2}/\d{2,4})|(\d+)\s+(day|week|month|year)s?\s+ago)",
    re.IGNORECASE,
)

PUBLIC_TEMPLATES = [
    {
        "id": NETWORK_TEMPLATE_ID,
        "slug": NETWORK_TEMPLATE_ID,
        "title": "LinkedIn Network Intelligence",
        "subtitle": "Use the same shell with your own connection export or copied LinkedIn network.",
        "description": (
            "A frozen interactive network-intelligence dashboard. "
            "The layout stays fixed; only the data and analysis update."
        ),
        "summary": "Built for LinkedIn connection CSV exports and pasted LinkedIn connection text.",
        "category": "analytics",
        "categories": ["analytics", "network-intelligence"],
        "engine_id": "template-instance-network-intelligence",
        "renderer_key": NETWORK_RENDERER_KEY,
        "input_schema_version": 1,
        "tags": ["linkedin", "network", "analysis", "template"],
        "best_for": "LinkedIn connection exports, relationship mapping, and deterministic network analysis.",
        "complexity": "low",
        "composable": False,
        "composition_role": "primary",
        "compatible_with": [],
        "quality_baseline": 94,
        "created_at": "2026-03-22",
        "created_by": "hand-built",
        "source_artifact_id": None,
        "match_aliases": [
            "linkedin intelligence",
            "network intelligence",
            "linkedin network dashboard",
            "connection intelligence",
        ],
        "supported_input_modes": ["csv", "paste"],
        "default_display_name": "Your",
        "slots": {
            "connections": {
                "type": "array",
                "required": True,
                "description": "Normalized LinkedIn connection rows.",
            },
            "summary": {
                "type": "object",
                "required": True,
                "description": "Computed counts and rollups used by the fixed dashboard shell.",
            },
        },
    }
]


def list_public_templates() -> dict[str, Any]:
    return {
        "template_count": len(PUBLIC_TEMPLATES),
        "templates": PUBLIC_TEMPLATES,
    }


def get_template_definition(template_id: str) -> dict[str, Any]:
    normalized = (template_id or "").strip().lower()
    for template in PUBLIC_TEMPLATES:
        if template["id"] == normalized or template["slug"] == normalized:
            return template
    raise ValueError(f'Unknown template "{template_id}".')


def _tokenize_match_text(value: str | None) -> set[str]:
    return {token for token in re.findall(r"[a-z0-9]{3,}", (value or "").lower())}


def match_public_templates(prompt: str, top_k: int = 3) -> dict[str, Any]:
    raw_prompt = (prompt or "").strip()
    if not raw_prompt:
        raise ValueError("Add a prompt before matching templates.")

    normalized = raw_prompt.lower()
    matches: list[dict[str, Any]] = []
    prompt_tokens = _tokenize_match_text(raw_prompt)

    for template in PUBLIC_TEMPLATES:
        template_id = template["id"]
        exact_targets = {
            template_id.lower(),
            str(template.get("slug") or "").lower(),
            str(template.get("title") or "").lower(),
            *(str(alias).lower() for alias in template.get("match_aliases") or []),
        }
        if normalized in exact_targets:
            matches.append(
                {
                    "template_id": template_id,
                    "name": template.get("title"),
                    "confidence": 1.0,
                    "match_method": "exact_name",
                    "engine_id": template.get("engine_id"),
                    "description": template.get("description"),
                }
            )
            continue

        tags = set(template.get("tags") or [])
        category_tokens = _tokenize_match_text(" ".join(template.get("categories") or []))
        description_tokens = _tokenize_match_text(template.get("description"))
        best_for_tokens = _tokenize_match_text(template.get("best_for"))
        alias_tokens = _tokenize_match_text(" ".join(template.get("match_aliases") or []))

        tag_overlap = len(prompt_tokens & tags)
        category_overlap = len(prompt_tokens & category_tokens)
        description_overlap = len(prompt_tokens & description_tokens)
        best_for_overlap = len(prompt_tokens & best_for_tokens)
        alias_overlap = len(prompt_tokens & alias_tokens)
        weighted = (tag_overlap * 3.0) + (category_overlap * 2.0) + best_for_overlap + description_overlap + alias_overlap * 2.0
        if weighted <= 0:
            continue

        denominator = max(4.0, len(tags) + len(category_tokens) * 0.5 + len(alias_tokens) * 0.5)
        confidence = min(0.99, round(weighted / denominator, 3))
        matches.append(
            {
                "template_id": template_id,
                "name": template.get("title"),
                "confidence": confidence,
                "match_method": "tag_match",
                "engine_id": template.get("engine_id"),
                "description": template.get("description"),
            }
        )

    matches.sort(key=lambda item: (-float(item.get("confidence") or 0.0), str(item.get("template_id") or "")))
    return {
        "prompt": raw_prompt,
        "match_count": len(matches[: max(1, int(top_k or 3))]),
        "matches": matches[: max(1, int(top_k or 3))],
    }


def parse_connected_on(value: str | None) -> tuple[str, str | None]:
    raw = (value or "").strip()
    if not raw:
        iso = datetime.now(timezone.utc).date().isoformat()
        return iso, None

    formats = [
        "%m/%d/%Y",
        "%m/%d/%y",
        "%Y-%m-%d",
        "%b %d, %Y",
        "%B %d, %Y",
    ]
    for date_format in formats:
        try:
            parsed = datetime.strptime(raw, date_format).date()
            return parsed.isoformat(), None
        except ValueError:
            continue

    match = CONNECTED_RECENCY_RE.search(raw)
    if match:
        exact_date = match.group(1)
        if exact_date:
            return parse_connected_on(exact_date)
        amount = int(match.group(2) or 0)
        unit = (match.group(3) or "").lower()
        days = 0
        if unit == "day":
            days = amount
        elif unit == "week":
            days = amount * 7
        elif unit == "month":
            days = amount * 30
        elif unit == "year":
            days = amount * 365
        parsed = datetime.now(timezone.utc).date() - timedelta(days=days)
        return parsed.isoformat(), raw

    return datetime.now(timezone.utc).date().isoformat(), raw


def build_quarter(iso_date: str) -> str:
    year = iso_date[0:4]
    month = int(iso_date[5:7])
    quarter = ((month - 1) // 3) + 1
    return f"{year}-Q{quarter}"


def build_connected_recency(iso_date: str) -> str:
    connected_date = datetime.strptime(iso_date, "%Y-%m-%d").date()
    delta_days = (datetime.now(timezone.utc).date() - connected_date).days
    if delta_days <= 30:
        return "last_30_days"
    if delta_days <= 90:
        return "last_90_days"
    if delta_days <= 365:
        return "last_365_days"
    return "older"


def classify_industry(occupation: str) -> str:
    occupation_lower = occupation.lower()
    if re.search(r"m&a|mergers|acquisitions|investment bank|business broker|corporate finance|private equity", occupation_lower):
        return "M&A / Investment Banking"
    if re.search(r"healthcare|medical|hospital|clinical|health|rcm|billing|dentist|dental|pharma|physician|behavioral|addiction|mental health|nursing|therapist|psychologist|treatment|wound|patient|nurse|clinic|surgery|rehab", occupation_lower):
        return "Healthcare"
    if re.search(r"real estate|realtor|property|mortgage", occupation_lower):
        return "Real Estate"
    if re.search(r"software|developer|full stack|saas|ai\s|machine learning|data science|cloud|devops|cybersec|tech|web develop|automation|cto|platform|data analyst|python|java|react|architect", occupation_lower):
        return "Technology"
    if re.search(r"cfo|finance|financial|accounting|accountant|cpa|fp&a|wealth|investment|capital|banking|controller|treasury", occupation_lower):
        return "Finance / Accounting"
    if re.search(r"recruit|talent acquisition|staffing|hr\s|human resources|headhunter", occupation_lower):
        return "Recruiting / HR"
    if re.search(r"marketing|brand|content|seo|digital market|social media|advertising|copywriter|creative director|communications", occupation_lower):
        return "Marketing / Media"
    if re.search(r"sales|account executive|business development|growth|revenue", occupation_lower):
        return "Sales / Business Development"
    if re.search(r"consult|advisory|advisor|strategist|coach|mentor", occupation_lower):
        return "Consulting / Advisory"
    if re.search(r"legal|attorney|lawyer|law\s", occupation_lower):
        return "Legal"
    if re.search(r"energy|renewable|solar|nextera|fpl", occupation_lower):
        return "Energy"
    if re.search(r"insurance|benefits|risk", occupation_lower):
        return "Insurance / Benefits"
    if re.search(r"founder|ceo|entrepreneur|co\.founder|owner|president", occupation_lower):
        return "Founder / Entrepreneur"
    if re.search(r"student|mba|university|college", occupation_lower):
        return "Education / Student"
    return "Other"


def classify_seniority(occupation: str) -> str:
    occupation_lower = occupation.lower()
    if re.search(r"ceo|chief executive|president|founder|co\.founder|owner|managing director|managing partner|partner\s|cfo|coo|cto|cmo|cio|chief ", occupation_lower):
        return "C-Suite / Founder"
    if re.search(r"vice president|vp\s|svp|evp", occupation_lower):
        return "VP / SVP"
    if re.search(r"director|head of", occupation_lower):
        return "Director"
    if re.search(r"manager|lead\s|supervisor|principal|senior", occupation_lower):
        return "Manager / Senior"
    if re.search(r"student|intern|associate|analyst|coordinator|specialist|representative", occupation_lower):
        return "Early Career"
    return "Individual Contributor"


def summarize_connections(connections: list[dict[str, Any]]) -> dict[str, Any]:
    industry_counts = Counter(connection["industry"] for connection in connections)
    seniority_counts = Counter(connection["seniority"] for connection in connections)
    top_industries = [
        {"label": label, "count": count}
        for label, count in industry_counts.most_common(5)
    ]
    top_seniority = [
        {"label": label, "count": count}
        for label, count in seniority_counts.most_common(5)
    ]
    c_suite_count = seniority_counts.get("C-Suite / Founder", 0)
    return {
        "connection_count": len(connections),
        "industry_count": len(industry_counts),
        "c_suite_count": c_suite_count,
        "c_suite_share": round((c_suite_count / len(connections)) if connections else 0, 3),
        "top_industry": top_industries[0]["label"] if top_industries else None,
        "top_industry_count": top_industries[0]["count"] if top_industries else 0,
        "top_industries": top_industries,
        "top_seniority": top_seniority,
        "latest_connected_on": max((connection["connected_on"] for connection in connections), default=None),
        "quarter_count": len({connection["quarter"] for connection in connections}),
    }


def _build_connection_record(
    *,
    name: str | None,
    headline: str,
    company: str | None,
    connected_raw: str | None,
    source_raw_block: str,
) -> dict[str, Any]:
    connected_on, fallback_reason = parse_connected_on(connected_raw)
    occupation = headline if not company else f"{headline} at {company}"
    return {
        "name": name.strip() if name else None,
        "headline": headline.strip(),
        "company": company.strip() if company else None,
        "connected_on": connected_on,
        "connected_recency": build_connected_recency(connected_on),
        "status": "active",
        "source_raw_block": source_raw_block.strip()[:600],
        "industry": classify_industry(occupation),
        "seniority": classify_seniority(occupation),
        "quarter": build_quarter(connected_on),
        "date_inferred": fallback_reason is not None,
    }


def normalize_csv_connections(raw_input: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], float]:
    reader = csv.DictReader(raw_input.splitlines())
    fieldnames = [field.strip() for field in (reader.fieldnames or []) if field]
    if "Position" not in fieldnames and "Company" not in fieldnames:
        raise ValueError("The CSV does not look like a LinkedIn connections export.")

    warnings: list[dict[str, Any]] = []
    connections: list[dict[str, Any]] = []
    inferred_dates = 0
    for row in reader:
        first_name = (row.get("First Name") or row.get("first_name") or "").strip()
        last_name = (row.get("Last Name") or row.get("last_name") or "").strip()
        name = " ".join(part for part in [first_name, last_name] if part).strip() or None
        headline = (row.get("Position") or row.get("Headline") or "").strip()
        company = (row.get("Company") or "").strip() or None
        connected_raw = (row.get("Connected On") or row.get("Connected on") or "").strip() or None
        if not headline and not company:
            continue
        source_raw_block = " | ".join(
            part for part in [name, headline, company or "", connected_raw or ""] if part
        )
        connection = _build_connection_record(
            name=name,
            headline=headline or company or "LinkedIn connection",
            company=company,
            connected_raw=connected_raw,
            source_raw_block=source_raw_block,
        )
        if connection["date_inferred"]:
            inferred_dates += 1
        connections.append(connection)

    if inferred_dates:
        warnings.append(
            {
                "severity": "medium",
                "type": "dates_inferred",
                "message": f"{inferred_dates} connection dates could not be parsed cleanly and were inferred.",
            }
        )

    confidence = 0.98 if connections else 0.0
    if inferred_dates:
        confidence = max(0.82, confidence - min(0.16, inferred_dates / max(len(connections), 1) * 0.3))
    return connections, warnings, round(confidence, 3)


def is_noise_line(line: str) -> bool:
    text = line.strip()
    if not text:
        return True
    lowered = text.lower()
    return any(re.search(pattern, lowered) for pattern in NOISE_LINE_PATTERNS)


def _normalize_paste_blocks(raw_input: str) -> list[list[str]]:
    lines = [line.strip() for line in raw_input.replace("\r", "").split("\n")]
    cleaned_lines = [line for line in lines if line and not is_noise_line(line)]
    blocks: list[list[str]] = []
    current_block: list[str] = []

    for line in cleaned_lines:
        if NAME_LINE_RE.match(line) and current_block:
            blocks.append(current_block)
            current_block = [line]
            continue
        current_block.append(line)
        if CONNECTED_RECENCY_RE.search(line):
            blocks.append(current_block)
            current_block = []

    if current_block:
        blocks.append(current_block)

    return blocks


def normalize_paste_connections(raw_input: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], float]:
    blocks = _normalize_paste_blocks(raw_input)
    warnings: list[dict[str, Any]] = []
    connections: list[dict[str, Any]] = []
    inferred_dates = 0
    named_records = 0
    fallback_records = 0

    for block in blocks:
        if not block:
            continue
        name: str | None = None
        headline_lines: list[str] = []
        connected_raw: str | None = None

        if NAME_LINE_RE.match(block[0]):
            name = block[0]
            named_records += 1
            remaining = block[1:]
        else:
            remaining = block

        for line in remaining:
            if CONNECTED_RECENCY_RE.search(line):
                connected_raw = line
            elif len(headline_lines) < 2:
                headline_lines.append(line)

        headline = " | ".join(headline_lines).strip()
        if not headline:
            fallback_records += 1
            headline = remaining[0] if remaining else block[0]

        source_raw_block = "\n".join(block)
        connection = _build_connection_record(
            name=name,
            headline=headline,
            company=None,
            connected_raw=connected_raw,
            source_raw_block=source_raw_block,
        )
        if connection["date_inferred"]:
            inferred_dates += 1
        connections.append(connection)

    if inferred_dates:
        warnings.append(
            {
                "severity": "medium",
                "type": "dates_inferred",
                "message": f"{inferred_dates} pasted blocks were missing a clean connected date, so IterFact inferred one.",
            }
        )

    if fallback_records:
        warnings.append(
            {
                "severity": "low",
                "type": "headline_fallback",
                "message": f"{fallback_records} pasted blocks were reduced to a best-effort headline.",
            }
        )

    confidence = 0.84 if connections else 0.0
    if named_records == 0 and connections:
        confidence = 0.64
        warnings.append(
            {
                "severity": "medium",
                "type": "low_confidence_name_parse",
                "message": "The pasted input did not include clean name lines, so IterFact treated most lines as headline-only records.",
            }
        )
    elif fallback_records:
        confidence = max(0.68, confidence - min(0.12, fallback_records / max(len(connections), 1) * 0.2))

    return connections, warnings, round(confidence, 3)


def detect_input_mode(raw_input: str, input_mode: str | None = None) -> str:
    declared = (input_mode or "").strip().lower()
    if declared in {"csv", "paste"}:
        return declared
    if "First Name" in raw_input and "Position" in raw_input:
        return "csv"
    return "paste"


def normalize_template_input(
    *,
    template_id: str,
    raw_input: str,
    input_mode: str | None = None,
) -> dict[str, Any]:
    template = get_template_definition(template_id)
    cleaned = (raw_input or "").strip()
    if not cleaned:
        raise ValueError("Add LinkedIn data before analyzing this template.")

    resolved_mode = detect_input_mode(cleaned, input_mode)
    if template["id"] != NETWORK_TEMPLATE_ID:
        raise ValueError(f'Template "{template_id}" does not have a normalizer yet.')

    if resolved_mode == "csv":
        connections, warnings, confidence = normalize_csv_connections(cleaned)
    else:
        connections, warnings, confidence = normalize_paste_connections(cleaned)

    if len(connections) < 3:
        raise ValueError(f"IterFact only parsed {len(connections)} usable connections. Add a larger LinkedIn sample.")

    summary = summarize_connections(connections)
    preview_rows = [
        {
            "name": connection["name"],
            "headline": connection["headline"],
            "industry": connection["industry"],
            "seniority": connection["seniority"],
            "connected_on": connection["connected_on"],
        }
        for connection in connections[:8]
    ]

    return {
        "template_id": template["id"],
        "template_title": template["title"],
        "input_mode": resolved_mode,
        "parse_confidence": confidence,
        "warning_count": len(warnings),
        "warnings": warnings,
        "summary": summary,
        "preview_rows": preview_rows,
        "normalized_data": {
            "connections": connections,
            "summary": summary,
        },
    }


def build_render_dataset(normalized_data: dict[str, Any]) -> list[dict[str, Any]]:
    rows = normalized_data.get("connections") if isinstance(normalized_data, dict) else None
    if not isinstance(rows, list):
        raise ValueError("The normalized template payload is missing connection rows.")
    dataset: list[dict[str, Any]] = []
    for row in rows:
        if not isinstance(row, dict):
            continue
        dataset.append(
            {
                "d": row.get("connected_on"),
                "i": row.get("industry"),
                "s": row.get("seniority"),
                "q": row.get("quarter"),
            }
        )
    if len(dataset) < 3:
        raise ValueError("The normalized template payload is missing enough usable records.")
    return dataset


def build_template_plan(
    *,
    template_id: str,
    normalized_data: dict[str, Any],
    display_name: str | None = None,
    network_label: str | None = None,
) -> dict[str, Any]:
    template = get_template_definition(template_id)
    summary = normalized_data.get("summary") if isinstance(normalized_data, dict) else {}
    if not isinstance(summary, dict):
        summary = {}

    owner_label = (display_name or "").strip() or template["default_display_name"]
    network_name = (network_label or "").strip() or f"{owner_label} Network"
    title = f"{owner_label} LinkedIn Network Intelligence"
    connection_count = int(summary.get("connection_count") or 0)
    industry_count = int(summary.get("industry_count") or 0)
    quarter_count = int(summary.get("quarter_count") or 0)
    latest_connected_on = str(summary.get("latest_connected_on") or "recent quarters")
    description = (
        f"{network_name} analyzed across industry mix, seniority access, connection velocity, "
        "and cross-segment density using IterFact's deterministic template renderer."
    )
    return {
        "wrapper": {
            "engine": "template-instance-network-intelligence",
        },
        "theme": "obsidian-cyan",
        "meta": {
            "title": title,
            "description": description,
            "document_format": "template instance",
            "template_match": "template_instance_network_intelligence",
        },
        "sections": [
            {
                "title": "Overview",
                "content": {
                    "headline": "Network overview",
                    "body": (
                        f"{connection_count} connections are spread across {industry_count} industries, "
                        "giving the dashboard a stable snapshot of reach and concentration."
                    ),
                },
            },
            {
                "title": "Network Galaxy",
                "content": {
                    "headline": "Industry clustering",
                    "body": (
                        f"The galaxy view maps where {network_name} is densest so high-signal pockets stand out first."
                    ),
                },
            },
            {
                "title": "Ranked Bars",
                "content": {
                    "headline": "Concentration by segment",
                    "body": (
                        f"{summary.get('top_industry') or 'Top segments'} currently leads the network, "
                        "with ranked bars preserving a clean comparison scale."
                    ),
                },
            },
            {
                "title": "Growth Timeline",
                "content": {
                    "headline": "Connection velocity over time",
                    "body": (
                        f"The timeline covers {quarter_count} quarter buckets and most recently updates through {latest_connected_on}."
                    ),
                },
            },
            {
                "title": "Seniority",
                "content": {
                    "headline": "Access by seniority",
                    "body": (
                        f"{int(summary.get('c_suite_count') or 0)} C-suite or founder relationships are already present in the network."
                    ),
                },
            },
            {
                "title": "Heatmap",
                "content": {
                    "headline": "Cross-segment overlap",
                    "body": (
                        "The matrix shows where industry and seniority intersect so follow-up targeting is easier to prioritize."
                    ),
                },
            },
            {
                "title": "Strategic Analysis",
                "content": {
                    "headline": "What to do next",
                    "body": (
                        "Analysis slots turn the normalized data into recommended next moves without changing the template structure."
                    ),
                },
            },
        ],
        "template_instance": {
            "template_id": template_id,
            "renderer_key": template["renderer_key"],
            "network_label": network_name,
            "display_name": owner_label,
            "summary": summary,
        },
    }


def render_template_instance(
    *,
    template_id: str,
    normalized_data: dict[str, Any],
    display_name: str | None = None,
    network_label: str | None = None,
) -> str:
    if template_id != NETWORK_TEMPLATE_ID:
        raise ValueError(f'Template "{template_id}" does not have a renderer yet.')

    source = NETWORK_TEMPLATE_PATH.read_text(encoding="utf-8")
    dataset = build_render_dataset(normalized_data)
    summary = normalized_data.get("summary") if isinstance(normalized_data, dict) else {}
    if not isinstance(summary, dict):
        summary = {}

    owner_label = (display_name or "").strip() or "Your"
    page_title = f"{owner_label} LinkedIn Network Intelligence"
    c_suite_count = int(summary.get("c_suite_count") or 0)
    top_industry = str(summary.get("top_industry") or "Network")
    top_industry_count = int(summary.get("top_industry_count") or 0)

    html = source
    html = html.replace("<title>Network Intelligence Dashboard</title>", f"<title>{page_title}</title>")
    html = re.sub(
        r"let RAW = .*?;\nconst IC=",
        f"let RAW = {json.dumps(dataset, ensure_ascii=True)};\nconst IC=",
        html,
        flags=re.DOTALL,
        count=1,
    )
    html = html.replace(
        '<span class="top-logo">Network Intelligence</span>',
        f'<span class="top-logo">{owner_label} Network Intelligence</span>',
    )
    html = html.replace(
        '<div class="top-kpis"><span class="top-kpi"><b id="topTotal">935</b> connections</span><span class="top-kpi"><b id="topIndustries">15</b> industries</span><span class="top-kpi"><b id="topCSuite">0</b> C-Suite</span></div>',
        (
            '<div class="top-kpis">'
            f'<span class="top-kpi"><b id="topTotal">{len(dataset)}</b> connections</span>'
            f'<span class="top-kpi"><b id="topIndustries">{int(summary.get("industry_count") or 0)}</b> industries</span>'
            f'<span class="top-kpi"><b id="topCSuite">{c_suite_count}</b> C-Suite</span>'
            "</div>"
        ),
    )
    html = html.replace(
        "<footer class=\"footer\"><p>Generated by <b>IterFact</b> &times; Claude &mdash; Interactive document intelligence</p></footer>",
        (
            "<footer class=\"footer\"><p>"
            f"Generated by <b>iterFact</b> template instances &mdash; {top_industry} leads with {top_industry_count} connections"
            "</p></footer>"
        ),
    )
    return html
