"""
Report generator for license-comply.

This module takes a ScanResult and produces output in one or more formats:
  - Terminal: Rich-formatted output with colors, tables, and panels
  - Markdown: A clean .md file using a Jinja2 template
  - HTML: A self-contained .html file with embedded CSS
  - JSON: Structured data for consumption by other tools

How it fits in the pipeline:
    scanner.py → resolver.py → classifier.py → analyzer.py → reporter.py

The terminal format is the default and uses the `rich` library for beautiful
output. The other formats generate files that can be shared or archived.
"""

from __future__ import annotations

import json
import os
from datetime import datetime

from jinja2 import Environment, FileSystemLoader, TemplateError
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text

from license_comply.models import Finding, ObligationSummary, RiskLevel, ScanResult
from license_comply.utils import _TEMPLATES_DIR

# ---------------------------------------------------------------------------
# AI disclaimer — shown alongside any AI-generated content
# ---------------------------------------------------------------------------

_AI_DISCLAIMER = (
    "NOTE: This summary was generated by an AI language model. It is not legal "
    "advice and has not been reviewed by an attorney. Consult qualified legal "
    "counsel before making compliance decisions."
)

# ---------------------------------------------------------------------------
# Symbols and styles for terminal output
# ---------------------------------------------------------------------------

_RISK_SYMBOLS: dict[str, str] = {
    "clean": "[green]✓[/green]",
    "notice": "[blue]ℹ[/blue]",
    "warning": "[yellow]⚠[/yellow]",
    "critical": "[red]✗[/red]",
}

_RISK_COLORS: dict[str, str] = {
    "clean": "green",
    "notice": "blue",
    "warning": "yellow",
    "critical": "red",
}


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def generate_report(
    result: ScanResult,
    formats: list[str],
    output_dir: str = ".",
    ci_mode: bool = False,
) -> list[str]:
    """Generate reports in one or more formats.

    Args:
        result: The complete scan result from the analyzer.
        formats: List of format strings (e.g., ["terminal", "markdown"]).
        output_dir: Directory to save file-based reports.
        ci_mode: If True, suppress decorative terminal output.

    Returns:
        A list of file paths for any generated report files.
        Terminal output is printed directly and not included in the list.
    """
    # Ensure the output directory exists — prevents FileNotFoundError when
    # saving report files to a directory that hasn't been created yet.
    # exist_ok=True means it silently succeeds if the directory already exists.
    if output_dir != ".":
        os.makedirs(output_dir, exist_ok=True)

    generated_files: list[str] = []

    for fmt in formats:
        if fmt == "terminal":
            _render_terminal(result, ci_mode=ci_mode)
        elif fmt == "markdown":
            filepath = _render_markdown(result, output_dir)
            if filepath:
                generated_files.append(filepath)
        elif fmt == "html":
            filepath = _render_html(result, output_dir)
            if filepath:
                generated_files.append(filepath)
        elif fmt == "json":
            filepath = _render_json(result, output_dir)
            if filepath:
                generated_files.append(filepath)

    return generated_files


# ---------------------------------------------------------------------------
# Terminal output (Rich)
# ---------------------------------------------------------------------------


def _render_terminal(result: ScanResult, ci_mode: bool = False) -> None:
    """Render the scan result to the terminal using Rich.

    Args:
        result: The scan result to display.
        ci_mode: If True, use plain text without colors.
    """
    if ci_mode:
        # no_color disables color codes; omitting force_terminal lets Rich
        # auto-detect that stdout is piped (not a TTY) in CI, so it skips
        # ALL ANSI escape codes — including bold/italic — producing plain text.
        console = Console(no_color=True)
    else:
        console = Console()

    # Count findings by risk level
    counts = _count_by_risk(result.findings)

    # --- Header ---
    console.print()
    console.print(f"[bold]license-comply v{result.tool_version}[/bold]")
    console.print(f"Scanning: {result.project_path}")
    console.print(f"Project type: {result.project_type.value}")
    console.print()

    # --- AI Summary (if present) ---
    if result.ai_summary:
        console.print(Panel(result.ai_summary, title="AI Executive Summary", border_style="blue"))
        console.print(f"\n[dim italic]{_AI_DISCLAIMER}[/dim italic]")
        console.print()

    # --- Summary ---
    console.print("[bold]" + "=" * 40 + "[/bold]")
    console.print("[bold]RESULTS SUMMARY[/bold]")
    console.print("[bold]" + "=" * 40 + "[/bold]")
    console.print(f"Total packages:  {result.total_packages}")
    if result.transitive_count > 0:
        console.print(f"  Direct: {result.direct_count} / Transitive: {result.transitive_count}")
    console.print(f"[green]Clean:           {counts.get('clean', 0)}[/green]")
    console.print(f"[blue]Notice:          {counts.get('notice', 0)}[/blue]")
    console.print(f"[yellow]Warnings:        {counts.get('warning', 0)}[/yellow]")
    console.print(f"[red]Critical:        {counts.get('critical', 0)}[/red]")
    console.print()

    # --- Dependency table ---
    table = Table(title="Dependencies", show_lines=True)
    table.add_column("Package", style="bold")
    table.add_column("License")
    table.add_column("Category")
    table.add_column("Risk", justify="center")

    # Sort: critical first, then warning, notice, clean
    sorted_findings = sorted(result.findings, key=lambda f: _risk_sort_key(f.risk_level))

    for finding in sorted_findings:
        risk_symbol = _RISK_SYMBOLS.get(finding.risk_level.value, "?")
        license_str = (
            finding.license_info.spdx_id or finding.license_info.declared_license or "Unknown"
        )
        category_str = finding.license_info.category.value.replace("_", " ").title()

        # Add a dim "(transitive)" tag after transitive package names
        package_display = finding.package_name
        if finding.license_info.is_transitive:
            package_display = Text.from_markup(f"{finding.package_name} [dim](transitive)[/dim]")

        table.add_row(
            package_display,
            license_str,
            category_str,
            Text.from_markup(f"{risk_symbol} {finding.risk_level.value.upper()}"),
        )

    console.print(table)
    console.print()

    # --- Critical findings ---
    critical_findings = [f for f in result.findings if f.risk_level == RiskLevel.CRITICAL]
    if critical_findings:
        console.print("[bold]" + "=" * 40 + "[/bold]")
        console.print("[bold red]CRITICAL ISSUES[/bold red]")
        console.print("[bold]" + "=" * 40 + "[/bold]")
        console.print()
        for finding in critical_findings:
            _print_finding(console, finding)

    # --- Warnings ---
    warning_findings = [f for f in result.findings if f.risk_level == RiskLevel.WARNING]
    if warning_findings:
        console.print("[bold]" + "=" * 40 + "[/bold]")
        console.print("[bold yellow]WARNINGS[/bold yellow]")
        console.print("[bold]" + "=" * 40 + "[/bold]")
        console.print()
        for finding in warning_findings:
            _print_finding(console, finding)

    # --- Obligation rollup ---
    if result.obligation_rollup:
        console.print("[bold]" + "=" * 40 + "[/bold]")
        console.print("[bold]YOUR OBLIGATIONS[/bold]")
        console.print("[bold]" + "=" * 40 + "[/bold]")
        console.print()
        for obligation in result.obligation_rollup:
            packages_str = ", ".join(
                f"{pkg} ({lid})" for pkg, lid in zip(obligation.packages, obligation.license_ids)
            )
            console.print(f"  • {obligation.obligation}")
            console.print(f"    Required by: {packages_str}")
            console.print()

    # Print legal disclaimer at the end of the report
    console.print("[dim]This report does not constitute legal advice.[/dim]")


def _print_finding(console: Console, finding: Finding) -> None:
    """Print a single finding with its details to the terminal.

    Args:
        console: The Rich Console instance for output.
        finding: The Finding object to display.
    """
    risk_symbol = _RISK_SYMBOLS.get(finding.risk_level.value, "?")
    license_str = finding.license_info.spdx_id or "UNKNOWN"
    color = _RISK_COLORS.get(finding.risk_level.value, "white")

    # Add a dim "(transitive)" tag after transitive package names in findings
    transitive_tag = " [dim](transitive)[/dim]" if finding.license_info.is_transitive else ""
    console.print(
        f"{risk_symbol} [bold]{finding.package_name}[/bold]{transitive_tag} ({license_str})"
    )
    risk_label = finding.risk_level.value.upper()
    console.print(f"  Risk: [{color}]{risk_label}[/{color}] — {finding.title}")
    console.print(f"  {finding.explanation}")
    console.print(f"  [bold]Action:[/bold] {finding.recommendation}")
    if finding.remediation_steps:
        console.print("  [bold]Next steps:[/bold]")
        for i, step in enumerate(finding.remediation_steps, start=1):
            console.print(f"    {i}. {step}")
    if finding.policy_violated:
        console.print(f"  [dim]Policy: {finding.policy_violated}[/dim]")
    console.print()


# ---------------------------------------------------------------------------
# Markdown output (Jinja2)
# ---------------------------------------------------------------------------


def _render_markdown(result: ScanResult, output_dir: str) -> str:
    """Render the scan result as a Markdown file.

    Args:
        result: The scan result.
        output_dir: Directory to save the file.

    Returns:
        The path to the generated Markdown file.
    """
    try:
        env = Environment(loader=FileSystemLoader(_TEMPLATES_DIR), autoescape=False)
        # Register a custom filter to escape pipe characters in markdown table
        # cells — an unescaped | would break the table layout.
        env.filters["escape_pipe"] = _escape_pipe
        template = env.get_template("report.md.jinja2")

        content = template.render(
            result=result,
            counts=_count_by_risk(result.findings),
            critical_findings=[f for f in result.findings if f.risk_level == RiskLevel.CRITICAL],
            warning_findings=[f for f in result.findings if f.risk_level == RiskLevel.WARNING],
            notice_findings=[f for f in result.findings if f.risk_level == RiskLevel.NOTICE],
            clean_findings=[f for f in result.findings if f.risk_level == RiskLevel.CLEAN],
            formatted_date=_format_timestamp(result.scan_timestamp),
        )
    except TemplateError as error:
        console = Console(stderr=True)
        console.print(f"[red]Error: Could not render Markdown report: {error}[/red]")
        console.print("  Hint: The Markdown template may be missing or corrupted.")
        console.print(f"  Expected location: {_TEMPLATES_DIR}")
        return ""

    filepath = os.path.join(output_dir, "license-comply-report.md")
    try:
        with open(filepath, "w", encoding="utf-8") as file:
            file.write(content)
    except OSError as error:
        console = Console(stderr=True)
        console.print(f"[red]Error: Could not write report to {filepath}: {error}[/red]")
        console.print("  Hint: Check that the output directory exists and is writable.")
        return ""

    return filepath


# ---------------------------------------------------------------------------
# HTML output (Jinja2)
# ---------------------------------------------------------------------------


def _render_html(result: ScanResult, output_dir: str) -> str:
    """Render the scan result as a self-contained HTML file.

    Args:
        result: The scan result.
        output_dir: Directory to save the file.

    Returns:
        The path to the generated HTML file.
    """
    try:
        env = Environment(loader=FileSystemLoader(_TEMPLATES_DIR), autoescape=True)
        template = env.get_template("report.html.jinja2")

        content = template.render(
            result=result,
            counts=_count_by_risk(result.findings),
            critical_findings=[f for f in result.findings if f.risk_level == RiskLevel.CRITICAL],
            warning_findings=[f for f in result.findings if f.risk_level == RiskLevel.WARNING],
            notice_findings=[f for f in result.findings if f.risk_level == RiskLevel.NOTICE],
            clean_findings=[f for f in result.findings if f.risk_level == RiskLevel.CLEAN],
            obligations_by_license=_build_obligations_by_license(result.obligation_rollup),
            obligations_by_package=_build_obligations_by_package(result.obligation_rollup),
            obligations_by_category=_build_obligations_by_category(result.obligation_rollup),
            formatted_date=_format_timestamp(result.scan_timestamp),
        )
    except TemplateError as error:
        console = Console(stderr=True)
        console.print(f"[red]Error: Could not render HTML report: {error}[/red]")
        console.print("  Hint: The HTML template may be missing or corrupted.")
        console.print(f"  Expected location: {_TEMPLATES_DIR}")
        return ""

    filepath = os.path.join(output_dir, "license-comply-report.html")
    try:
        with open(filepath, "w", encoding="utf-8") as file:
            file.write(content)
    except OSError as error:
        console = Console(stderr=True)
        console.print(f"[red]Error: Could not write report to {filepath}: {error}[/red]")
        console.print("  Hint: Check that the output directory exists and is writable.")
        return ""

    return filepath


# ---------------------------------------------------------------------------
# JSON output
# ---------------------------------------------------------------------------


def _render_json(result: ScanResult, output_dir: str) -> str:
    """Render the scan result as a JSON file.

    Args:
        result: The scan result.
        output_dir: Directory to save the file.

    Returns:
        The path to the generated JSON file.
    """
    data = _scan_result_to_dict(result)

    filepath = os.path.join(output_dir, "license-comply-report.json")
    try:
        with open(filepath, "w", encoding="utf-8") as file:
            json.dump(data, file, indent=2, sort_keys=False)
    except OSError as error:
        console = Console(stderr=True)
        console.print(f"[red]Error: Could not write report to {filepath}: {error}[/red]")
        console.print("  Hint: Check that the output directory exists and is writable.")
        return ""

    return filepath


def _scan_result_to_dict(result: ScanResult) -> dict:
    """Convert a ScanResult to a plain dictionary for JSON serialization."""
    return {
        "project_path": result.project_path,
        "project_type": result.project_type.value,
        "total_packages": result.total_packages,
        "direct_count": result.direct_count,
        "transitive_count": result.transitive_count,
        "scan_timestamp": result.scan_timestamp,
        "tool_version": result.tool_version,
        "ai_summary": result.ai_summary,
        "ai_summary_disclaimer": _AI_DISCLAIMER if result.ai_summary else None,
        "findings": [
            {
                "package_name": f.package_name,
                "declared_license": f.license_info.declared_license,
                "spdx_id": f.license_info.spdx_id,
                "category": f.license_info.category.value,
                "risk_level": f.risk_level.value,
                "is_transitive": f.license_info.is_transitive,
                "title": f.title,
                "explanation": f.explanation,
                "recommendation": f.recommendation,
                "remediation_steps": f.remediation_steps,
                "policy_violated": f.policy_violated,
                "classification_note": f.license_info.classification_note,
                "pypi_url": f.license_info.pypi_url,
                "source_url": f.license_info.source_url,
            }
            for f in result.findings
        ],
        "obligation_rollup": [
            {
                "obligation": o.obligation,
                "packages": o.packages,
                "license_ids": o.license_ids,
            }
            for o in result.obligation_rollup
        ],
    }


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _escape_pipe(value: str) -> str:
    """Escape pipe characters for use in markdown table cells.

    An unescaped | in a markdown table cell would be interpreted as a column
    separator, breaking the table layout. This filter replaces | with \\|.

    Args:
        value: The string to escape.

    Returns:
        The string with | replaced by \\|.
    """
    if not isinstance(value, str):
        value = str(value)
    return value.replace("|", "\\|")


def _count_by_risk(findings: list[Finding]) -> dict[str, int]:
    """Count findings by risk level.

    Args:
        findings: The list of Finding objects from the analyzer.

    Returns:
        A dictionary mapping risk level names to counts.
    """
    counts: dict[str, int] = {"clean": 0, "notice": 0, "warning": 0, "critical": 0}
    for finding in findings:
        counts[finding.risk_level.value] = counts.get(finding.risk_level.value, 0) + 1
    return counts


def _risk_sort_key(risk_level: RiskLevel) -> int:
    """Sort key for risk levels: critical first, clean last.

    Args:
        risk_level: The RiskLevel enum value to sort.

    Returns:
        An integer sort key (lower = shown first in reports).
    """
    order = {
        RiskLevel.CRITICAL: 0,
        RiskLevel.WARNING: 1,
        RiskLevel.NOTICE: 2,
        RiskLevel.CLEAN: 3,
    }
    return order.get(risk_level, 99)


def _format_timestamp(raw_timestamp: str) -> str:
    """Convert an ISO 8601 timestamp to a human-readable date string.

    Takes a timestamp like "2026-02-25T14:30:00+00:00" and returns
    "February 25, 2026". Falls back to returning the raw string if
    parsing fails — so the template never breaks.

    Args:
        raw_timestamp: An ISO 8601 timestamp string.

    Returns:
        A formatted date like "February 25, 2026", or the raw string
        if parsing fails.
    """
    try:
        parsed = datetime.fromisoformat(raw_timestamp)
        return parsed.strftime("%B %d, %Y")
    except (ValueError, TypeError):
        return raw_timestamp


# Human-readable display names for obligation categories.
# These map from the short keys used in licenses.yaml to the labels shown
# in the HTML report's "By Obligation" tab.
_OBLIGATION_CATEGORY_LABELS: dict[str, str] = {
    "notice": "Notice & Attribution",
    "disclosure": "Modification Disclosure",
    "source_sharing": "Source Code Sharing",
    "use_restriction": "Use Restrictions",
    "technical": "Technical Requirements",
}

# Display order for obligation categories (most common → most serious).
_OBLIGATION_CATEGORY_ORDER: list[str] = [
    "notice",
    "disclosure",
    "source_sharing",
    "use_restriction",
    "technical",
]


def _build_obligations_by_license(
    obligation_rollup: list[ObligationSummary],
) -> list[dict]:
    """Regroup obligations by license instead of by obligation text.

    The existing obligation_rollup is grouped by obligation text:
        "Include copyright notice" → [requests (MIT), flask (BSD-3-Clause)]

    This helper inverts that into a list grouped by license:
        MIT → packages: [requests], obligations: ["Include copyright notice"]

    This grouping is more natural for reading in a legal report — you see
    each license, which packages use it, and what you must do.

    Args:
        obligation_rollup: The list of ObligationSummary objects from the
            analyzer.

    Returns:
        A list of dicts, each with keys: "license_id", "packages",
        "obligations". Sorted alphabetically by license_id.
    """
    # Build a mapping: license_id → {packages: set, obligations: list}
    license_map: dict[str, dict] = {}

    for obligation_summary in obligation_rollup:
        for package_name, license_id in zip(
            obligation_summary.packages, obligation_summary.license_ids
        ):
            if license_id not in license_map:
                license_map[license_id] = {"packages": set(), "obligations": []}

            license_map[license_id]["packages"].add(package_name)

            # Only add each obligation text once per license
            if obligation_summary.obligation not in license_map[license_id]["obligations"]:
                license_map[license_id]["obligations"].append(obligation_summary.obligation)

    # Convert to sorted list of dicts (sets → sorted lists for consistency)
    result = []
    for license_id in sorted(license_map.keys()):
        entry = license_map[license_id]
        result.append(
            {
                "license_id": license_id,
                "packages": sorted(entry["packages"]),
                "obligations": entry["obligations"],
            }
        )

    return result


def _build_obligations_by_package(
    obligation_rollup: list[ObligationSummary],
) -> list[dict]:
    """Regroup obligations by package instead of by obligation text.

    Produces a view like:
        requests (MIT) → ["Include copyright notice and license text"]
        chardet (LGPL-3.0-only) → ["Distribute modifications under LGPL", ...]

    This lets users quickly see "what do I need to do for THIS package?"

    Args:
        obligation_rollup: The list of ObligationSummary objects from the
            analyzer.

    Returns:
        A list of dicts, each with keys: "package_name", "license_id",
        "obligations". Sorted alphabetically by package_name.
    """
    # Build a mapping: package_name → {license_ids (set), obligations}
    package_map: dict[str, dict] = {}

    for obligation_summary in obligation_rollup:
        for package_name, license_id in zip(
            obligation_summary.packages, obligation_summary.license_ids
        ):
            if package_name not in package_map:
                package_map[package_name] = {"license_ids": set(), "obligations": []}

            # Track all license IDs associated with this package
            package_map[package_name]["license_ids"].add(license_id)

            # Only add each obligation text once per package
            if obligation_summary.obligation not in package_map[package_name]["obligations"]:
                package_map[package_name]["obligations"].append(obligation_summary.obligation)

    # Convert to sorted list of dicts for the template.
    # Join multiple license IDs with " + " for display (e.g., "MIT + GPL-3.0-only").
    result = []
    for package_name in sorted(package_map.keys()):
        entry = package_map[package_name]
        result.append(
            {
                "package_name": package_name,
                "license_id": " + ".join(sorted(entry["license_ids"])),
                "obligations": entry["obligations"],
            }
        )

    return result


def _build_obligations_by_category(
    obligation_rollup: list[ObligationSummary],
) -> list[dict]:
    """Group obligations by their category for a more readable view.

    Instead of a flat list of obligations, this produces groups like:
        Notice & Attribution:
            - Include copyright notice (required by: requests, flask)
        Source Code Sharing:
            - Distribute source under GPL (required by: gpl-pkg)

    This makes it much easier to scan for specific types of obligations
    than reading through an unsorted flat list.

    Args:
        obligation_rollup: The list of ObligationSummary objects from the
            analyzer.

    Returns:
        A list of dicts, each with keys: "category_key", "category_label",
        "obligations". Each obligation is a dict with "text", "packages",
        "license_ids". Ordered by _OBLIGATION_CATEGORY_ORDER.
    """
    # Build a mapping: category_key → list of obligation dicts
    category_map: dict[str, list[dict]] = {}

    for obligation_summary in obligation_rollup:
        category_key = obligation_summary.category or "notice"

        if category_key not in category_map:
            category_map[category_key] = []

        category_map[category_key].append(
            {
                "text": obligation_summary.obligation,
                "packages": obligation_summary.packages,
                "license_ids": obligation_summary.license_ids,
            }
        )

    # Convert to ordered list following _OBLIGATION_CATEGORY_ORDER
    result = []
    for category_key in _OBLIGATION_CATEGORY_ORDER:
        if category_key in category_map:
            result.append(
                {
                    "category_key": category_key,
                    "category_label": _OBLIGATION_CATEGORY_LABELS.get(
                        category_key, category_key.replace("_", " ").title()
                    ),
                    "obligations": category_map[category_key],
                }
            )

    # Include any categories not in the predefined order (future-proofing)
    for category_key in sorted(category_map.keys()):
        if category_key not in _OBLIGATION_CATEGORY_ORDER:
            result.append(
                {
                    "category_key": category_key,
                    "category_label": _OBLIGATION_CATEGORY_LABELS.get(
                        category_key, category_key.replace("_", " ").title()
                    ),
                    "obligations": category_map[category_key],
                }
            )

    return result
