"""
OctoLearn Intelligence Report Generator
========================================

Generates professional, narrative-driven PDF reports that tell the story
of your data — from raw input to trained models and actionable insights.

Features:
- Professional light theme with readable dark text
- Auto-discovered OctoLearn logo on cover + page headers
- Storytelling narrative flow across 8+ sections
- Color-coded Data Health Dashboard with risk gauge
- Before/After data transformation comparison
- Model Arena with populated benchmark tables
- Priority-grouped recommendations (Critical/High/Medium/Low)
- Page headers and footers with branding

Author: OctoLearn Development Team
Version: 0.8.0 (Redesigned)
License: MIT
"""

import os
import tempfile
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Optional, Union
from io import BytesIO
import warnings

import pandas as pd
import numpy as np

from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch, mm
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT, TA_JUSTIFY
from reportlab.platypus import (
    SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle,
    PageBreak, KeepTogether, HRFlowable
)
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
from reportlab.graphics.shapes import Drawing, Rect, String, Circle
from reportlab.graphics import renderPDF

# Safe import with fallback
try:
    from PIL import Image as PILImage
    PIL_AVAILABLE = True
except ImportError:
    PIL_AVAILABLE = False

try:
    import matplotlib
    matplotlib.use('Agg')  # Non-interactive backend
    import matplotlib.pyplot as plt
    import matplotlib.patches as mpatches
    MATPLOTLIB_AVAILABLE = True
except ImportError:
    MATPLOTLIB_AVAILABLE = False

warnings.filterwarnings("ignore")

try:
    from ..utils.helpers import setup_logger
except ImportError:
    import logging
    def setup_logger(name):
        return logging.getLogger(name)

logger = setup_logger(__name__)


# ============================================================================
# COLOR PALETTE — Professional Light Theme
# ============================================================================

class ReportColors:
    """Professional light-theme color palette for readable PDF output."""

    # === Primary brand colors ===
    PRIMARY = colors.HexColor('#1B3A5C')       # Deep navy blue (headers)
    PRIMARY_LIGHT = colors.HexColor('#2D5F8A')  # Lighter navy
    ACCENT = colors.HexColor('#E74C3C')         # Warm red accent

    # === Backgrounds ===
    WHITE = colors.HexColor('#FFFFFF')
    PAGE_BG = colors.HexColor('#FFFFFF')        # White paper
    CARD_BG = colors.HexColor('#F7F9FC')        # Subtle blue-grey cards
    HEADER_BG = colors.HexColor('#1B3A5C')      # Navy header bars
    ROW_ALT = colors.HexColor('#F0F4F8')        # Alternating table rows

    # === Text colors ===
    TEXT_PRIMARY = colors.HexColor('#1A1A2E')   # Near-black for body text
    TEXT_SECONDARY = colors.HexColor('#4A5568')  # Muted grey for secondary
    TEXT_ON_DARK = colors.HexColor('#FFFFFF')    # White text on dark headers
    TEXT_CAPTION = colors.HexColor('#718096')    # Caption grey

    # === Status/Semantic colors ===
    SUCCESS = colors.HexColor('#27AE60')        # Emerald green
    WARNING = colors.HexColor('#F39C12')        # Amber
    DANGER = colors.HexColor('#E74C3C')         # Red
    INFO = colors.HexColor('#3498DB')           # Sky blue

    # === Risk gauge ===
    RISK_LOW = colors.HexColor('#27AE60')
    RISK_MODERATE = colors.HexColor('#F39C12')
    RISK_HIGH = colors.HexColor('#E74C3C')

    # === Decorative ===
    DIVIDER = colors.HexColor('#CBD5E0')        # Light divider line
    BORDER = colors.HexColor('#E2E8F0')         # Card borders

    @staticmethod
    def get_risk_color(score: int) -> colors.Color:
        if score <= 30:
            return ReportColors.RISK_LOW
        elif score <= 60:
            return ReportColors.RISK_MODERATE
        else:
            return ReportColors.RISK_HIGH

    @staticmethod
    def get_risk_label(score: int) -> str:
        if score <= 30:
            return "Healthy"
        elif score <= 60:
            return "Needs Attention"
        else:
            return "Critical"


# ============================================================================
# LOGO DISCOVERY
# ============================================================================

def _find_logo() -> Optional[str]:
    """Auto-discover the OctoLearn logo from common locations."""
    candidates = [
        Path(__file__).parent.parent / 'images' / 'logo.png',
        Path(__file__).parent.parent / 'images' / 'logo.jpg',
        Path('octolearn') / 'images' / 'logo.png',
        Path.cwd() / 'octolearn' / 'images' / 'logo.png',
    ]
    for p in candidates:
        if p.exists():
            return str(p.resolve())
    return None


# ============================================================================
# CUSTOM PAGE TEMPLATE — Headers & Footers
# ============================================================================




# ============================================================================
# REPORT GENERATOR — MAIN CLASS
# ============================================================================

class ReportGenerator:
    """
    Professional PDF Report Generator with intelligent storytelling.

    Produces narrative-driven reports that guide users through:
    1. Cover Page — branding and dataset overview
    2. Data Story — narrative introduction
    3. Data Health Dashboard — risk score and quality metrics
    4. Before & After — data transformation journey
    5. Feature Intelligence — importance rankings
    6. Model Arena — benchmark comparison
    7. Recommendations — priority-grouped actions
    8. Visual Insights — charts and correlation maps
    """

    def __init__(
        self,
        raw_profile: Any,
        clean_profile: Any,
        mode: str = 'detailed',
        dist_plots: Optional[List[str]] = None,
        heatmap_plot: Optional[str] = None,
        recommendations: Optional[Union[List[str], Dict[str, List[str]]]] = None,
        risk_score: Optional[int] = None,
        risk_category: Optional[str] = None,
        risk_factors: Optional[Dict[str, Any]] = None,
        preprocessing_suggestions: Optional[Dict[str, Any]] = None,
        feature_importance: Optional[Dict[str, float]] = None,
        shap_path: Optional[str] = None,
        model_benchmarks: Optional[List[Dict[str, Any]]] = None,
        best_model_name: Optional[str] = None,
        logo_path: Optional[str] = None,
        cleaning_log: Optional[Dict] = None,
        outlier_results: Optional[Dict] = None,
        interaction_results: Optional[Dict] = None,
        title: str = "OctoLearn Intelligence Report",
        author: str = "OctoLearn AutoML",
        company: str = "Data Science Team",
        # Raw and cleaned DataFrames for before/after distribution plots
        raw_X: Optional[Any] = None,
        clean_X: Optional[Any] = None,
        corr_summary: Optional[Dict[str, Any]] = None,  # From PlotGenerator.generate_correlation_heatmap
    ):
        if mode not in ['brief', 'detailed']:
            raise ValueError(f"Mode must be 'brief' or 'detailed', got '{mode}'")

        self.raw_profile = raw_profile
        self.clean_profile = clean_profile
        self.mode = mode
        self.dist_plots = dist_plots or []
        self.heatmap_plot = heatmap_plot
        self.corr_summary = corr_summary or {}  # {top_pairs, bottom_pairs, n_features, strategy}
        self.recommendations = recommendations or []
        self.risk_score = risk_score if risk_score is not None else 50
        self.risk_category = risk_category or "Moderate"
        self.risk_factors = risk_factors or {}
        self.preprocessing_suggestions = preprocessing_suggestions or {}
        self.feature_importance = feature_importance or {}
        self.shap_path = shap_path
        self.model_benchmarks = model_benchmarks or []
        self.best_model_name = best_model_name or "N/A"
        self.logo_path = logo_path or _find_logo()
        self.cleaning_log = cleaning_log or {}
        self.outlier_results = outlier_results or {}
        self.interaction_results = interaction_results or {}
        self.title = title
        self.author = author
        self.company = company
        self.raw_X = raw_X
        self.clean_X = clean_X
        # Temp files created during report generation (cleaned up after)
        self._temp_files: List[str] = []

        self._register_fonts()
        self.styles = getSampleStyleSheet()
        self._create_styles()

        logger.info(f"Report initialized in {mode.upper()} mode")

    # ------------------------------------------------------------------
    # Font Registration
    # ------------------------------------------------------------------
    def _register_fonts(self):
        """Load ShantellSans fonts with fallback to Helvetica."""
        self.font_regular = 'Helvetica'
        self.font_bold = 'Helvetica-Bold'
        self.font_title = 'Helvetica-Bold'
        self.font_italic = 'Helvetica-Oblique'

        possible_dirs = [
            Path(__file__).parent.parent / 'fonts',
            Path('octolearn') / 'fonts',
            Path('fonts'),
            Path.cwd() / 'octolearn' / 'fonts',
        ]

        font_files = {
            'ShantellSans-Regular': 'ShantellSans-Regular.ttf',
            'ShantellSans-Bold': 'ShantellSans-Bold.ttf',
            'ShantellSans-ExtraBold': 'ShantellSans-ExtraBold.ttf',
            'ShantellSans-Italic': 'ShantellSans-Italic.ttf',
        }

        for font_name, filename in font_files.items():
            for font_dir in possible_dirs:
                font_path = font_dir / filename
                if font_path.exists():
                    try:
                        pdfmetrics.registerFont(TTFont(font_name, str(font_path)))
                        if font_name == 'ShantellSans-Regular':
                            self.font_regular = font_name
                        elif font_name == 'ShantellSans-ExtraBold':
                            self.font_title = font_name
                        elif font_name == 'ShantellSans-Bold':
                            self.font_bold = font_name
                        elif font_name == 'ShantellSans-Italic':
                            self.font_italic = font_name
                        break
                    except Exception:
                        pass

        if self.font_regular == 'ShantellSans-Regular':
            logger.info("Successfully registered ShantellSans fonts.")
        else:
            logger.warning("Could not register ShantellSans fonts. Using Helvetica.")

    # ------------------------------------------------------------------
    # Style Definitions
    # ------------------------------------------------------------------
    def _create_styles(self):
        """Create professional readable paragraph styles."""

        self.styles.add(ParagraphStyle(
            name='CoverTitle',
            fontName=self.font_title,
            fontSize=30,
            leading=36,
            textColor=ReportColors.PRIMARY,
            spaceAfter=10,
            alignment=TA_CENTER,
        ))

        self.styles.add(ParagraphStyle(
            name='CoverSubtitle',
            fontName=self.font_regular,
            fontSize=13,
            leading=18,
            textColor=ReportColors.TEXT_SECONDARY,
            spaceAfter=6,
            alignment=TA_CENTER,
        ))

        self.styles.add(ParagraphStyle(
            name='SectionHeading',
            fontName=self.font_title,
            fontSize=16,
            leading=20,
            textColor=ReportColors.WHITE,
            backColor=ReportColors.PRIMARY,
            borderPadding=(10, 5, 10, 5),  # Top, Right, Bottom, Left
            spaceBefore=18,  # Increased top spacing
            spaceAfter=12,   # Increased bottom spacing
            allowWidows=0,
            allowOrphans=0,
        ))




        self.styles.add(ParagraphStyle(
            name='SubsectionHeading',
            fontName=self.font_bold,
            fontSize=13,
            leading=17,
            textColor=ReportColors.PRIMARY_LIGHT,
            spaceBefore=12,
            spaceAfter=6,
        ))

        self.styles.add(ParagraphStyle(
            name='ReportBody',
            fontName=self.font_regular,
            fontSize=10.5,
            leading=15,
            textColor=ReportColors.TEXT_PRIMARY,
            spaceAfter=8,
            alignment=TA_JUSTIFY,
        ))

        self.styles.add(ParagraphStyle(
            name='ReportBold',
            fontName=self.font_bold,
            fontSize=10.5,
            leading=15,
            textColor=ReportColors.TEXT_PRIMARY,
            spaceAfter=8,
        ))

        self.styles.add(ParagraphStyle(
            name='Narrative',
            fontName=self.font_regular,
            fontSize=11,
            leading=17,
            textColor=ReportColors.TEXT_PRIMARY,
            spaceAfter=10,
            alignment=TA_JUSTIFY,
        ))

        self.styles.add(ParagraphStyle(
            name='TableCell',
            fontName=self.font_regular,
            fontSize=9.5,
            leading=13,
            textColor=ReportColors.TEXT_PRIMARY,
        ))

        self.styles.add(ParagraphStyle(
            name='TableHeader',
            fontName=self.font_bold,
            fontSize=10,
            leading=13,
            textColor=ReportColors.TEXT_ON_DARK,
        ))

        self.styles.add(ParagraphStyle(
            name='MetricValue',
            fontName=self.font_bold,
            fontSize=22,
            leading=26,
            textColor=ReportColors.PRIMARY,
            alignment=TA_CENTER,
        ))

        self.styles.add(ParagraphStyle(
            name='MetricLabel',
            fontName=self.font_regular,
            fontSize=9,
            leading=12,
            textColor=ReportColors.TEXT_SECONDARY,
            alignment=TA_CENTER,
        ))

        self.styles.add(ParagraphStyle(
            name='Caption',
            fontName=self.font_regular,
            fontSize=9,
            leading=12,
            textColor=ReportColors.TEXT_CAPTION,
            spaceAfter=6,
            alignment=TA_CENTER,
        ))

        self.styles.add(ParagraphStyle(
            name='Highlight',
            fontName=self.font_bold,
            fontSize=11,
            leading=15,
            textColor=ReportColors.ACCENT,
            spaceAfter=8,
        ))

        self.styles.add(ParagraphStyle(
            name='RecCritical',
            fontName=self.font_bold,
            fontSize=10,
            leading=14,
            textColor=ReportColors.DANGER,
            spaceBefore=4,
            spaceAfter=4,
            leftIndent=12,
        ))

        self.styles.add(ParagraphStyle(
            name='RecHigh',
            fontName=self.font_bold,
            fontSize=10,
            leading=14,
            textColor=ReportColors.WARNING,
            spaceBefore=4,
            spaceAfter=4,
            leftIndent=12,
        ))

        self.styles.add(ParagraphStyle(
            name='RecNormal',
            fontName=self.font_regular,
            fontSize=10,
            leading=14,
            textColor=ReportColors.TEXT_PRIMARY,
            spaceBefore=3,
            spaceAfter=3,
            leftIndent=12,
        ))

    # ------------------------------------------------------------------
    # Helper: divider line
    # ------------------------------------------------------------------
    def _divider(self):
        return HRFlowable(
            width="100%", thickness=1,
            color=ReportColors.DIVIDER,
            spaceBefore=8, spaceAfter=8
        )

    # ------------------------------------------------------------------
    # Helper: styled table
    # ------------------------------------------------------------------
    def _styled_table(self, data, col_widths, highlight_best_row=None):
        """Create a professionally styled table with header and alternating rows."""
        t = Table(data, colWidths=col_widths)
        style_cmds = [
            # Header row
            ('BACKGROUND', (0, 0), (-1, 0), ReportColors.HEADER_BG),
            ('TEXTCOLOR', (0, 0), (-1, 0), ReportColors.TEXT_ON_DARK),
            ('FONTNAME', (0, 0), (-1, 0), self.font_bold),
            ('FONTSIZE', (0, 0), (-1, 0), 10),
            # Body
            ('FONTNAME', (0, 1), (-1, -1), self.font_regular),
            ('FONTSIZE', (0, 1), (-1, -1), 9.5),
            ('TEXTCOLOR', (0, 1), (-1, -1), ReportColors.TEXT_PRIMARY),
            # Alignment
            ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
            ('ALIGN', (0, 0), (0, -1), 'LEFT'),
            ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
            # Padding
            ('TOPPADDING', (0, 0), (-1, -1), 7),
            ('BOTTOMPADDING', (0, 0), (-1, -1), 7),
            ('LEFTPADDING', (0, 0), (-1, -1), 8),
            ('RIGHTPADDING', (0, 0), (-1, -1), 8),
            # Grid
            ('GRID', (0, 0), (-1, 0), 0, ReportColors.HEADER_BG),
            ('LINEBELOW', (0, 0), (-1, 0), 1.5, ReportColors.PRIMARY),
            ('LINEBELOW', (0, 1), (-1, -2), 0.5, ReportColors.BORDER),
            ('LINEBELOW', (0, -1), (-1, -1), 1, ReportColors.DIVIDER),
            # Alternating rows
            ('ROWBACKGROUNDS', (0, 1), (-1, -1), [ReportColors.WHITE, ReportColors.ROW_ALT]),
        ]
        # Highlight a specific row (e.g., best model)
        if highlight_best_row is not None and highlight_best_row >= 1:
            style_cmds.append(('BACKGROUND', (0, highlight_best_row), (-1, highlight_best_row), colors.HexColor('#EBF5FB')))
            style_cmds.append(('TEXTCOLOR', (0, highlight_best_row), (-1, highlight_best_row), ReportColors.PRIMARY))
            style_cmds.append(('FONTNAME', (0, highlight_best_row), (-1, highlight_best_row), self.font_bold))

        t.setStyle(TableStyle(style_cmds))
        return t

    def _add_section_header(self, story, title):
        """Add a professional full-width section header."""
        # Use a Table to create a full-width colored bar
        header_style = ParagraphStyle(
            'SectionHeaderBar',
            parent=self.styles['SectionHeading'],
            alignment=TA_LEFT
        )
        p = Paragraph(title.upper(), header_style)
        
        # 100% width table
        t = Table([[p]], colWidths=[7.5*inch])
        t.setStyle(TableStyle([
            ('BACKGROUND', (0, 0), (-1, -1), ReportColors.PRIMARY),
            ('TOPPADDING', (0, 0), (-1, -1), 0),
            ('BOTTOMPADDING', (0, 0), (-1, -1), 0),
            ('LEFTPADDING', (0, 0), (-1, -1), 0),
            ('RIGHTPADDING', (0, 0), (-1, -1), 0),
        ]))
        story.append(t)
        story.append(Spacer(1, 0.25 * inch))  # Increased spacing after header bar

    def _create_side_by_side(self, left_content, right_content, widths=[3.7*inch, 3.7*inch]):
        """Create a 2-column layout using a Table."""
        data = [[left_content, right_content]]
        t = Table(data, colWidths=widths)
        t.setStyle(TableStyle([
            ('VALIGN', (0, 0), (-1, -1), 'TOP'),
            ('LEFTPADDING', (0, 0), (-1, -1), 2),
            ('RIGHTPADDING', (0, 0), (-1, -1), 2),
            ('TOPPADDING', (0, 0), (-1, -1), 0),
        ]))
        return t


    # ==================================================================
    # SECTION 1: COVER PAGE
    # ==================================================================
    def _add_cover_page(self, story):
        story.append(Spacer(1, 0.8 * inch))

        # Logo
        if self.logo_path and os.path.exists(self.logo_path):
            try:
                im = Image(self.logo_path, width=1.8 * inch, height=1.8 * inch)
                im.hAlign = 'CENTER'
                story.append(im)
            except Exception:
                pass

        story.append(Spacer(1, 0.4 * inch))

        # Title
        story.append(Paragraph(self.title, self.styles['CoverTitle']))
        story.append(Spacer(1, 0.15 * inch))

        # Subtitle line
        story.append(self._divider())
        story.append(Spacer(1, 0.1 * inch))

        # Metadata cards
        task_type = getattr(self.raw_profile, 'task_type', 'N/A') if self.raw_profile else 'N/A'
        n_rows = getattr(self.raw_profile, 'n_rows', '?') if self.raw_profile else '?'
        n_cols = getattr(self.raw_profile, 'n_columns', '?') if self.raw_profile else '?'

        meta_data = [
            [
                Paragraph("<b>Generated By</b>", self.styles['TableCell']),
                Paragraph(self.author, self.styles['TableCell']),
                Paragraph("<b>Date</b>", self.styles['TableCell']),
                Paragraph(datetime.now().strftime("%B %d, %Y at %H:%M"), self.styles['TableCell']),
            ],
            [
                Paragraph("<b>Task Type</b>", self.styles['TableCell']),
                Paragraph(str(task_type).title(), self.styles['TableCell']),
                Paragraph("<b>Dataset Size</b>", self.styles['TableCell']),
                Paragraph(f"{n_rows} rows x {n_cols} features", self.styles['TableCell']),
            ],
            [
                Paragraph("<b>Risk Score</b>", self.styles['TableCell']),
                Paragraph(f"{self.risk_score}/100 ({self.risk_category})", self.styles['TableCell']),
                Paragraph("<b>Best Model</b>", self.styles['TableCell']),
                Paragraph(str(self.best_model_name), self.styles['TableCell']),
            ],
        ]

        meta_table = Table(meta_data, colWidths=[1.3 * inch, 2.0 * inch, 1.3 * inch, 2.0 * inch])
        meta_table.setStyle(TableStyle([
            ('BACKGROUND', (0, 0), (-1, -1), ReportColors.CARD_BG),
            ('TEXTCOLOR', (0, 0), (-1, -1), ReportColors.TEXT_PRIMARY),
            ('FONTNAME', (0, 0), (-1, -1), self.font_regular),
            ('FONTSIZE', (0, 0), (-1, -1), 10),
            ('TOPPADDING', (0, 0), (-1, -1), 8),
            ('BOTTOMPADDING', (0, 0), (-1, -1), 8),
            ('LEFTPADDING', (0, 0), (-1, -1), 10),
            ('GRID', (0, 0), (-1, -1), 0.5, ReportColors.BORDER),
            ('BOX', (0, 0), (-1, -1), 1, ReportColors.DIVIDER),
        ]))
        story.append(meta_table)

        story.append(Spacer(1, 0.5 * inch))

        # Tagline
        story.append(Paragraph(
            "This report was auto-generated by OctoLearn's intelligent pipeline. "
            "It narrates the complete journey of your data -- from raw input through cleaning, "
            "profiling, model training, and actionable recommendations.",
            self.styles['Caption']
        ))

        story.append(PageBreak())

    # ==================================================================
    # SECTION 2: DATA STORY (Narrative)
    # ==================================================================
    def _add_data_story(self, story):
        self._add_section_header(story, "The Data Story")

        n_rows = getattr(self.raw_profile, 'n_rows', 0) if self.raw_profile else 0
        n_cols = getattr(self.raw_profile, 'n_columns', 0) if self.raw_profile else 0
        task = getattr(self.raw_profile, 'task_type', 'unknown') if self.raw_profile else 'unknown'
        n_numeric = len(getattr(self.raw_profile, 'numeric_columns', [])) if self.raw_profile else 0
        n_cat = len(getattr(self.raw_profile, 'categorical_columns', [])) if self.raw_profile else 0
        n_missing_cols = sum(1 for v in getattr(self.raw_profile, 'missing_ratio', {}).values() if v > 0) if self.raw_profile else 0
        dupes = getattr(self.raw_profile, 'duplicate_rows', 0) if self.raw_profile else 0

        # Opening narrative
        story.append(Paragraph(
            f"Your dataset arrived with <b>{n_rows:,}</b> records and <b>{n_cols}</b> features, "
            f"setting up a <b>{task}</b> challenge. OctoLearn's 6-phase intelligent pipeline "
            f"examined every corner of this data to extract maximum insight.",
            self.styles['Narrative']
        ))

        # Feature composition
        story.append(Paragraph(
            f"The features break down into <b>{n_numeric} numeric</b> and <b>{n_cat} categorical</b> columns. "
            f"Of the {n_cols} total features, <b>{n_missing_cols}</b> contained missing values that "
            f"required intelligent imputation strategies.",
            self.styles['Narrative']
        ))

        # Data quality narrative
        if dupes > 0:
            story.append(Paragraph(
                f"OctoLearn detected <b>{dupes} duplicate rows</b> lurking in the data. "
                f"These were automatically removed during the cleaning phase to prevent "
                f"data leakage and inflated model performance.",
                self.styles['Narrative']
            ))

        # Risk narrative
        risk_color_name = "green" if self.risk_score <= 30 else ("amber" if self.risk_score <= 60 else "red")
        story.append(Paragraph(
            f"After deep analysis, OctoLearn assigned a <b>Data Quality Risk Score of "
            f"{self.risk_score}/100</b> ({self.risk_category}). "
            f"This places your dataset in the <b>{risk_color_name} zone</b> -- "
            + ("minimal risk factors were detected. Your data is well-structured and ready for modeling."
               if self.risk_score <= 30 else
               "some quality issues were found that OctoLearn addressed automatically. Review the recommendations for further improvements."
               if self.risk_score <= 60 else
               "significant quality concerns were identified. OctoLearn applied automated fixes, but manual review is strongly recommended."),
            self.styles['Narrative']
        ))

        # Model narrative
        if self.model_benchmarks:
            n_models = len(self.model_benchmarks)
            best_name = self.best_model_name
            best_score = self.model_benchmarks[0].get('score', 0) if self.model_benchmarks else 0
            story.append(Paragraph(
                f"OctoLearn trained and evaluated <b>{n_models} models</b> with hyperparameter "
                f"optimization. After rigorous benchmarking, <b>{best_name}</b> emerged as the "
                f"champion with a score of <b>{best_score:.4f}</b>. "
                f"See the Model Arena section for full comparison.",
                self.styles['Narrative']
            ))
        else:
            story.append(Paragraph(
                "Model training was not performed in this run. "
                "Set <b>train_models=True</b> in ModelingConfig to see model benchmarks.",
                self.styles['Narrative']
            ))



    # ==================================================================
    # SECTION 3: DATA HEALTH DASHBOARD
    # ==================================================================
    def _add_health_dashboard(self, story):
        self._add_section_header(story, "Data Health Dashboard")

        # --- Risk Score Card (Left Content) ---
        risk_color = ReportColors.get_risk_color(self.risk_score)
        risk_label = ReportColors.get_risk_label(self.risk_score)
        
        # Compact Risk Table
        risk_data = [[
            Paragraph(f'<font size="24" color="{risk_color.hexval()}">{self.risk_score}</font>'
                      f'<font size="10" color="{ReportColors.TEXT_SECONDARY.hexval()}">/100</font>',
                      ParagraphStyle('RiskNum', parent=self.styles['ReportBody'], alignment=TA_CENTER)),
            Paragraph(f'<font size="12" color="{risk_color.hexval()}"><b>{risk_label}</b></font><br/>'
                      f'<font size="8" color="{ReportColors.TEXT_CAPTION.hexval()}">Risk Score</font>',
                      ParagraphStyle('RiskLabel', parent=self.styles['ReportBody'],
                                     alignment=TA_LEFT, leading=14)),
        ]]
        risk_table = Table(risk_data, colWidths=[1.1 * inch, 1.8 * inch])
        risk_table.setStyle(TableStyle([
            ('BACKGROUND', (0, 0), (-1, -1), ReportColors.CARD_BG),
            ('BOX', (0, 0), (-1, -1), 1, risk_color),
            ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
            ('TOPPADDING', (0, 0), (-1, -1), 12),
            ('BOTTOMPADDING', (0, 0), (-1, -1), 12),
        ]))

        # --- Metric Cards (Right Content) ---
        n_rows = getattr(self.raw_profile, 'n_rows', 0) if self.raw_profile else 0
        n_cols = getattr(self.raw_profile, 'n_columns', 0) if self.raw_profile else 0
        missing_pct = 0
        if self.raw_profile and hasattr(self.raw_profile, 'missing_ratio'):
            ratios = list(self.raw_profile.missing_ratio.values())
            missing_pct = round(sum(ratios) / max(len(ratios), 1) * 100, 1)
        dupes = getattr(self.raw_profile, 'duplicate_rows', 0) if self.raw_profile else 0
        dupe_pct = round(dupes / max(n_rows, 1) * 100, 1) if n_rows else 0

        # Transposed 2x2 Grid for compact side-by-side
        metric_grid = [
             [
                 Paragraph(f"<b>{n_rows:,}</b><br/><font size=8>Rows</font>", ParagraphStyle('M1', parent=self.styles['MetricValue'], leading=12)),
                 Paragraph(f"<b>{n_cols}</b><br/><font size=8>Features</font>", ParagraphStyle('M2', parent=self.styles['MetricValue'], leading=12)),
             ],
             [
                 Paragraph(f"<b>{missing_pct}%</b><br/><font size=8>Cells Missing</font>", ParagraphStyle('M3', parent=self.styles['MetricValue'], leading=12)),
                 Paragraph(f"<b>{dupe_pct}%</b><br/><font size=8>Dupes</font>", ParagraphStyle('M4', parent=self.styles['MetricValue'], leading=12)),
             ]
        ]
        
        card_table = Table(metric_grid, colWidths=[1.9 * inch, 1.9 * inch])
        card_table.setStyle(TableStyle([
            ('BACKGROUND', (0, 0), (-1, -1), ReportColors.CARD_BG),
            ('GRID', (0, 0), (-1, -1), 0.5, ReportColors.BORDER),
            ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
            ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
            ('TOPPADDING', (0, 0), (-1, -1), 10),
            ('BOTTOMPADDING', (0, 0), (-1, -1), 10),
        ]))

        # Layout Side-by-Side
        layout = self._create_side_by_side(risk_table, card_table, widths=[3.0 * inch, 4.0 * inch])
        story.append(layout)
        story.append(Spacer(1, 0.2 * inch))


        # --- Risk Factors Table ---
        if self.risk_factors:
            story.append(Paragraph("Risk Factors Identified", self.styles['SubsectionHeading']))
            story.append(Spacer(1, 0.1 * inch))

            rf_data = [['Factor', 'Description', 'Severity']]

            def _get_sort_score(item):
                val = item[1]
                if isinstance(val, dict):
                    return val.get('score', 0)
                if isinstance(val, (int, float)):
                    return val
                return 0

            sorted_factors = sorted(self.risk_factors.items(), key=_get_sort_score, reverse=True)

            for factor, data in sorted_factors[:8]:
                if isinstance(data, dict):
                    score = data.get('score', 0)
                    desc = data.get('description', str(data))
                elif isinstance(data, (int, float)):
                    score = data
                    desc = str(data)
                else:
                    score = 0
                    desc = str(data)

                sev_val = score * 10
                sev_color = ReportColors.get_risk_color(sev_val)
                sev_label = "Low" if sev_val <= 30 else ("Med" if sev_val <= 60 else "High")

                rf_data.append([
                    Paragraph(str(factor).replace('_', ' ').title(), self.styles['TableCell']),
                    Paragraph(desc[:80], self.styles['TableCell']),
                    Paragraph(f'<font color="{sev_color.hexval()}"><b>{sev_label}</b></font>',
                              ParagraphStyle('Sev', parent=self.styles['TableCell'], alignment=TA_CENTER)),
                ])

            rf_table = self._styled_table(rf_data, [1.5 * inch, 3.8 * inch, 0.8 * inch])
            story.append(rf_table)



    # ==================================================================
    # SECTION 4: BEFORE & AFTER — Data Transformation Journey
    # ==================================================================
    def _generate_before_after_plots(self) -> Optional[str]:
        """
        Generate a before/after distribution comparison figure for the top
        numeric features. Returns path to a temp PNG file, or None if
        matplotlib is unavailable or no suitable columns exist.
        """
        if not MATPLOTLIB_AVAILABLE:
            return None
        if self.raw_X is None or self.clean_X is None:
            return None

        try:
            raw_df = self.raw_X
            clean_df = self.clean_X

            # Find numeric columns present in both DataFrames
            raw_num = [c for c in raw_df.columns
                       if raw_df[c].dtype.kind in ('i', 'u', 'f')]
            clean_num = [c for c in clean_df.columns
                         if clean_df[c].dtype.kind in ('i', 'u', 'f')]
            # Prefer columns that exist in both (before encoding)
            shared = [c for c in raw_num if c in clean_num]
            cols = shared[:4] if shared else raw_num[:4]
            if not cols:
                return None

            n_cols = len(cols)
            fig, axes = plt.subplots(n_cols, 2, figsize=(10, 2.8 * n_cols))
            if n_cols == 1:
                axes = [axes]  # Ensure iterable

            NAVY = '#1B3A5C'
            TEAL = '#27AE60'
            ALPHA = 0.75

            for i, col in enumerate(cols):
                ax_raw = axes[i][0]
                ax_clean = axes[i][1]

                raw_data = raw_df[col].dropna()
                ax_raw.hist(raw_data, bins=30, color=NAVY, alpha=ALPHA, edgecolor='white', linewidth=0.4)
                ax_raw.set_title(f'{col}  [RAW]', fontsize=9, fontweight='bold', color=NAVY)
                ax_raw.set_ylabel('Count', fontsize=8)
                ax_raw.tick_params(labelsize=7)
                ax_raw.spines['top'].set_visible(False)
                ax_raw.spines['right'].set_visible(False)

                if col in clean_df.columns:
                    clean_data = clean_df[col].dropna()
                else:
                    clean_data = raw_data  # Fallback
                ax_clean.hist(clean_data, bins=30, color=TEAL, alpha=ALPHA, edgecolor='white', linewidth=0.4)
                ax_clean.set_title(f'{col}  [CLEAN]', fontsize=9, fontweight='bold', color=TEAL)
                ax_clean.tick_params(labelsize=7)
                ax_clean.spines['top'].set_visible(False)
                ax_clean.spines['right'].set_visible(False)

            fig.suptitle('Feature Distributions: Before vs After Cleaning',
                         fontsize=11, fontweight='bold', color=NAVY, y=1.01)
            plt.tight_layout()

            tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
            fig.savefig(tmp.name, dpi=130, bbox_inches='tight',
                        facecolor='white', edgecolor='none')
            plt.close(fig)
            self._temp_files.append(tmp.name)
            return tmp.name

        except Exception as e:
            logger.warning(f"Before/after plot generation failed: {e}")
            return None

    def _add_before_after(self, story):
        self._add_section_header(story, "Data Transformation Journey")

        story.append(Paragraph(
            "OctoLearn's intelligent cleaning pipeline automatically transforms your raw data "
            "into a clean, model-ready dataset. Here is the before-and-after comparison:",
            self.styles['Narrative']
        ))
        story.append(Spacer(1, 0.15 * inch))

        raw = self.raw_profile
        clean = self.clean_profile
        if not raw or not clean:
            story.append(Paragraph("Profile data not available for comparison.", self.styles['Narrative']))
            return

        # Comparison table
        raw_missing = sum(1 for v in getattr(raw, 'missing_ratio', {}).values() if v > 0)
        clean_missing = sum(1 for v in getattr(clean, 'missing_ratio', {}).values() if v > 0)

        compare_data = [
            ['Metric', 'Before (Raw)', 'After (Clean)', 'Change'],
            [
                Paragraph('Rows', self.styles['TableCell']),
                Paragraph(f'{raw.n_rows:,}', self.styles['TableCell']),
                Paragraph(f'{clean.n_rows:,}', self.styles['TableCell']),
                Paragraph(f'{clean.n_rows - raw.n_rows:+,}', self.styles['TableCell']),
            ],
            [
                Paragraph('Features', self.styles['TableCell']),
                Paragraph(f'{raw.n_columns}', self.styles['TableCell']),
                Paragraph(f'{clean.n_columns}', self.styles['TableCell']),
                Paragraph(f'{clean.n_columns - raw.n_columns:+d}', self.styles['TableCell']),
            ],
            [
                Paragraph('Numeric Columns', self.styles['TableCell']),
                Paragraph(f'{len(raw.numeric_columns)}', self.styles['TableCell']),
                Paragraph(f'{len(clean.numeric_columns)}', self.styles['TableCell']),
                Paragraph(f'{len(clean.numeric_columns) - len(raw.numeric_columns):+d}', self.styles['TableCell']),
            ],
            [
                Paragraph('Categorical Columns', self.styles['TableCell']),
                Paragraph(f'{len(raw.categorical_columns)}', self.styles['TableCell']),
                Paragraph(f'{len(clean.categorical_columns)}', self.styles['TableCell']),
                Paragraph(f'{len(clean.categorical_columns) - len(raw.categorical_columns):+d}', self.styles['TableCell']),
            ],
            [
                Paragraph('Cols with Missing Values', self.styles['TableCell']),
                Paragraph(f'{raw_missing}', self.styles['TableCell']),
                Paragraph(f'{clean_missing}', self.styles['TableCell']),
                Paragraph(f'{clean_missing - raw_missing:+d}', self.styles['TableCell']),
            ],
        ]

        compare_table = self._styled_table(compare_data, [1.8 * inch, 1.5 * inch, 1.5 * inch, 1.3 * inch])
        story.append(compare_table)
        story.append(Spacer(1, 0.2 * inch))

        # Cleaning Log narrative
        if self.cleaning_log:
            story.append(Paragraph("What OctoLearn Did", self.styles['SubsectionHeading']))
            story.append(Spacer(1, 0.05 * inch))

            actions = []
            dupes = self.cleaning_log.get('duplicates_removed', 0)
            if dupes and dupes > 0:
                actions.append(f"Removed <b>{dupes}</b> duplicate rows to prevent data leakage")

            id_cols = self.cleaning_log.get('id_columns_removed', 0)
            if id_cols and id_cols > 0:
                actions.append(f"Dropped <b>{id_cols}</b> ID-like columns (no predictive value)")

            const_cols = self.cleaning_log.get('constant_columns_removed', 0)
            if const_cols and const_cols > 0:
                actions.append(f"Removed <b>{const_cols}</b> constant columns (zero variance)")

            low_var = self.cleaning_log.get('low_variance_removed', 0)
            if low_var and low_var > 0:
                actions.append(f"Dropped <b>{low_var}</b> low-variance features")

            imputed = self.cleaning_log.get('missing_imputed', 0)
            if imputed and imputed > 0:
                actions.append(f"Imputed missing values across <b>{imputed}</b> columns")

            if imputed and imputed > 0:
                actions.append(f"Imputed missing values across <b>{imputed}</b> columns")
            
            # Add Encoding Narrative
            ordinal = self.cleaning_log.get('ordinal_encoder')
            ohe = self.cleaning_log.get('onehot_encoder')
            if ordinal or ohe:
                actions.append("Encoded categorical variables (Ordinal/OneHot) for model compatibility")
            
            # Add Scaling Narrative
            scaling = self.cleaning_log.get('scaling_method', 'none')
            if scaling and scaling != 'none':
                actions.append(f"Standardized features using <b>{scaling}</b> scaling")

            if not actions:
                actions.append("Data was already clean — no major transformations needed")

            for action in actions:
                story.append(Paragraph(f"  ➜  {action}", self.styles['Narrative']))

        story.append(Spacer(1, 0.2 * inch))

        # Before/After distribution plots
        ba_plot_path = self._generate_before_after_plots()
        if ba_plot_path and os.path.exists(ba_plot_path):
            story.append(Paragraph("Distribution Comparison: Raw vs Cleaned",
                                   self.styles['SubsectionHeading']))
            story.append(Spacer(1, 0.05 * inch))
            story.append(Paragraph(
                "The histograms below show how OctoLearn's cleaning pipeline changed the "
                "distribution of your top numeric features. Navy = raw data, Green = cleaned data.",
                self.styles['Narrative']
            ))
            story.append(Spacer(1, 0.1 * inch))
            try:
                ba_img = Image(ba_plot_path, width=7.2 * inch, height=None)
                # Preserve aspect ratio
                from PIL import Image as PILImg
                with PILImg.open(ba_plot_path) as pil_img:
                    w_px, h_px = pil_img.size
                aspect = h_px / w_px
                ba_img = Image(ba_plot_path, width=7.2 * inch, height=7.2 * inch * aspect)
                ba_img.hAlign = 'CENTER'
                story.append(ba_img)
            except Exception:
                # Fallback without PIL
                try:
                    ba_img = Image(ba_plot_path, width=7.2 * inch, height=4.5 * inch)
                    ba_img.hAlign = 'CENTER'
                    story.append(ba_img)
                except Exception as e:
                    logger.warning(f"Could not embed before/after plot: {e}")



    # ==================================================================
    # SECTION 5: FEATURE INTELLIGENCE
    # ==================================================================
    def _add_feature_intelligence(self, story):
        if not self.feature_importance:
            return

        self._add_section_header(story, "Feature Intelligence")

        story.append(Paragraph(
            "OctoLearn calculated feature importance scores to identify which variables "
            "have the strongest predictive power. Higher scores indicate greater influence "
            "on the model's decisions.",
            self.styles['Narrative']
        ))
        story.append(Spacer(1, 0.1 * inch))

        # Sort features
        sorted_feats = sorted(self.feature_importance.items(), key=lambda x: x[1], reverse=True)
        limit = 10  # Show top 10 in table
        top_feats = sorted_feats[:limit]

        if not top_feats:
            return

        max_score = top_feats[0][1] if top_feats else 1

        fi_data = [['Rank', 'Feature', 'Score']]
        for rank, (feat, score) in enumerate(top_feats, 1):
            # Truncate long feature names
            feat_str = str(feat)
            if len(feat_str) > 20:
                feat_str = feat_str[:18] + ".."
            
            fi_data.append([
                Paragraph(f'<b>{rank}</b>', ParagraphStyle('R', parent=self.styles['TableCell'], alignment=TA_CENTER)),
                Paragraph(feat_str, self.styles['TableCell']),
                Paragraph(f'{score:.4f}', ParagraphStyle('S', parent=self.styles['TableCell'], alignment=TA_CENTER)),
            ])

        # Left Column: Table (Compact)
        fi_table = self._styled_table(fi_data, [0.4 * inch, 1.8 * inch, 0.8 * inch])
        
        # Right Column: SHAP Plot or Placeholder
        right_content = []
        if self.shap_path and os.path.exists(self.shap_path):
            right_content.append(Paragraph("SHAP Feature Impact", self.styles['SubsectionHeading']))
            try:
                # Resize for column width (approx 3.5 inch)
                shap_img = Image(self.shap_path, width=3.8 * inch, height=2.8 * inch)
                shap_img.hAlign = 'CENTER'
                right_content.append(shap_img)
                right_content.append(Paragraph(
                    "Red = Higher prediction<br/>Blue = Lower prediction",
                    self.styles['Caption']
                ))
            except Exception:
                pass
        else:
            # Generate an inline horizontal bar chart using matplotlib
            bar_path = self._generate_importance_bar_chart(top_feats, max_score)
            if bar_path and os.path.exists(bar_path):
                try:
                    bar_img = Image(bar_path, width=3.8 * inch, height=2.8 * inch)
                    bar_img.hAlign = 'CENTER'
                    right_content.append(bar_img)
                    right_content.append(Paragraph(
                        "Feature importance scores (higher = more predictive)",
                        self.styles['Caption']
                    ))
                except Exception:
                    pass
            if not right_content:
                right_content.append(Paragraph(
                    "<b>Why Feature Intelligence Matters</b><br/><br/>"
                    "Understanding which features drive your model's predictions is crucial "
                    "for trust and debugging. The table on the left shows the top contributors.",
                    self.styles['Narrative']
                ))

        layout = self._create_side_by_side(fi_table, right_content, widths=[3.2 * inch, 4.0 * inch])
        story.append(layout)
        story.append(Spacer(1, 0.2 * inch))




    # ==================================================================
    # SECTION 6: MODEL ARENA
    # ==================================================================
    def _add_model_arena(self, story):
        if not self.model_benchmarks:
            return

        self._add_section_header(story, "Model Arena")


        n_models = len(self.model_benchmarks)
        best = self.model_benchmarks[0] if self.model_benchmarks else {}
        best_name = best.get('model', self.best_model_name)
        best_score = best.get('score', 0)

        story.append(Paragraph(
            f"OctoLearn trained <b>{n_models} models</b> with automated hyperparameter tuning. "
            f"Each model was evaluated on a held-out test set to ensure unbiased comparison. "
            f"The champion is <b>{best_name}</b> "
            f"with a primary score of <b>{best_score:.4f}</b>.",
            self.styles['Narrative']
        ))
        story.append(Spacer(1, 0.15 * inch))

        # Champion card
        champion_data = [[
            Paragraph(f'<font size="10" color="{ReportColors.PRIMARY.hexval()}">CHAMPION</font><br/>'
                      f'<font size="16" color="{ReportColors.PRIMARY.hexval()}"><b>{best_name}</b></font>',
                      ParagraphStyle('Champ', parent=self.styles['Narrative'], alignment=TA_CENTER, leading=22)),
        ]]
        champ_table = Table(champion_data, colWidths=[6.2 * inch])
        champ_table.setStyle(TableStyle([
            ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#EBF5FB')),
            ('BOX', (0, 0), (-1, -1), 2, ReportColors.PRIMARY),
            ('TOPPADDING', (0, 0), (-1, -1), 12),
            ('BOTTOMPADDING', (0, 0), (-1, -1), 12),
        ]))
        story.append(champ_table)
        story.append(Spacer(1, 0.2 * inch))

        # Determine task type from profile or benchmarks
        task_type = getattr(self.raw_profile, 'task_type', 'classification') if self.raw_profile else 'classification'
        is_regression = task_type == 'regression'

        if is_regression:
            header = ['#', 'Model', 'Score', 'RMSE', 'MAE', 'R²', 'MAPE']
        else:
            header = ['#', 'Model', 'Score', 'Accuracy', 'Precision', 'F1', 'Recall']
        bench_data = [header]

        best_row_idx = None
        limit = 5 if self.mode == 'brief' else 10
        for idx, bm in enumerate(self.model_benchmarks[:limit], 1):
            model_name = bm.get('model', 'Unknown')
            score = bm.get('score', 0)
            metrics = bm.get('metrics', {})

            if is_regression:
                col3 = metrics.get('rmse', score)
                col4 = metrics.get('mae', 0)
                col5 = metrics.get('r2', 0)
                col6 = metrics.get('mape', 0)
            else:
                col3 = metrics.get('accuracy', score)
                col4 = metrics.get('precision', 0)
                col5 = metrics.get('f1', 0)
                col6 = metrics.get('recall', 0)

            if model_name == best_name or (best_row_idx is None and idx == 1):
                best_row_idx = idx

            bench_data.append([
                Paragraph(f'{idx}', ParagraphStyle('Idx', parent=self.styles['TableCell'], alignment=TA_CENTER)),
                Paragraph(f'<b>{model_name}</b>' if idx == 1 else model_name, self.styles['TableCell']),
                Paragraph(f'{score:.4f}', ParagraphStyle('Sc', parent=self.styles['TableCell'], alignment=TA_CENTER)),
                Paragraph(f'{col3:.4f}', ParagraphStyle('C3', parent=self.styles['TableCell'], alignment=TA_CENTER)),
                Paragraph(f'{col4:.4f}', ParagraphStyle('C4', parent=self.styles['TableCell'], alignment=TA_CENTER)),
                Paragraph(f'{col5:.4f}', ParagraphStyle('C5', parent=self.styles['TableCell'], alignment=TA_CENTER)),
                Paragraph(f'{col6:.4f}', ParagraphStyle('C6', parent=self.styles['TableCell'], alignment=TA_CENTER)),
            ])

        bench_table = self._styled_table(
            bench_data,
            [0.35 * inch, 1.6 * inch, 0.75 * inch, 0.85 * inch, 0.85 * inch, 0.75 * inch, 0.75 * inch],
            highlight_best_row=best_row_idx
        )
        story.append(bench_table)
        story.append(Spacer(1, 0.1 * inch))
        story.append(Paragraph(
            "Models are ranked by primary optimization score. The #1 model (highlighted) was selected as the best performer.",
            self.styles['Caption']
        ))



    # ==================================================================
    # SECTION 7: RECOMMENDATIONS
    # ==================================================================
    def _add_recommendations(self, story):
        if not self.recommendations:
            return

        self._add_section_header(story, "Actionable Recommendations")

        story.append(Paragraph(
            "Based on the comprehensive analysis, OctoLearn has generated the following "
            "prioritized recommendations to further improve your data and models:",
            self.styles['Narrative']
        ))
        story.append(Spacer(1, 0.1 * inch))

        # Flatten recs into priority groups
        priority_groups = {}
        if isinstance(self.recommendations, dict):
            for priority in ['critical', 'high', 'medium', 'low', 'informational']:
                if priority in self.recommendations and self.recommendations[priority]:
                    priority_groups[priority] = self.recommendations[priority]
            # Catch any non-standard keys
            for k, v in self.recommendations.items():
                if k not in priority_groups and isinstance(v, list) and v:
                    priority_groups.setdefault('medium', []).extend(v)
        elif isinstance(self.recommendations, list):
            priority_groups['medium'] = self.recommendations

        if not priority_groups:
            story.append(Paragraph("No specific recommendations at this time.", self.styles['Narrative']))
            return

        # Priority styling
        priority_config = {
            'critical': ('[CRITICAL]', ReportColors.DANGER, self.styles['RecCritical']),
            'high':     ('[HIGH]', ReportColors.WARNING, self.styles['RecHigh']),
            'medium':   ('[MEDIUM]', ReportColors.INFO, self.styles['RecNormal']),
            'low':      ('[LOW]', ReportColors.TEXT_CAPTION, self.styles['RecNormal']),
            'informational': ('[INFO]', ReportColors.TEXT_CAPTION, self.styles['RecNormal']),
        }

        limit = 5 if self.mode == 'brief' else 15
        count = 0
        for priority, recs in priority_groups.items():
            if count >= limit:
                break
            label, color, style = priority_config.get(priority, ('[INFO]', ReportColors.TEXT_CAPTION, self.styles['RecNormal']))

            story.append(Paragraph(
                f'<font color="{color.hexval()}" size="11"><b>{priority.upper()} PRIORITY</b></font>',
                ParagraphStyle('PriorityHeader', parent=self.styles['Narrative'],
                               spaceBefore=10, spaceAfter=4)
            ))

            for rec in recs:
                if count >= limit:
                    break
                count += 1
                # Truncate long recs
                rec_text = str(rec)[:200]
                story.append(Paragraph(
                    f'<font color="{color.hexval()}"><b>{label}</b></font>  {rec_text}',
                    style
                ))

            story.append(Spacer(1, 0.05 * inch))



    # ==================================================================
    # SECTION 8: VISUAL INSIGHTS
    # ==================================================================
    def _add_visual_insights(self, story):
        """Add correlation visualization and feature distribution plots."""
        has_visuals = bool(self.dist_plots) or bool(self.heatmap_plot)
        if not has_visuals:
            return

        self._add_section_header(story, "Visual Insights")

        story.append(Paragraph(
            "The following visualizations provide deeper insight into feature distributions "
            "and correlations within your cleaned dataset.",
            self.styles['Narrative']
        ))
        story.append(Spacer(1, 0.1 * inch))

        # ── Correlation narrative paragraph ──────────────────────────────────
        if self.corr_summary:
            story.append(Paragraph("Correlation Analysis", self.styles['SubsectionHeading']))
            n_features = self.corr_summary.get('n_features', 0)
            strategy = self.corr_summary.get('strategy', 'none')
            top_pairs = self.corr_summary.get('top_pairs', [])
            bottom_pairs = self.corr_summary.get('bottom_pairs', [])

            # Build plain-English narrative
            if n_features >= 2 and top_pairs:
                # Top correlated pairs
                top_strs = []
                for a, b, c in top_pairs[:3]:
                    direction = "positively" if c > 0 else "negatively"
                    top_strs.append(f"<b>{a}</b> and <b>{b}</b> ({direction}, r={c:.2f})")

                # Bottom correlated pairs (least correlated = near 0)
                bottom_strs = []
                for a, b, c in bottom_pairs[:2]:
                    bottom_strs.append(f"<b>{a}</b> and <b>{b}</b> (r={c:.2f})")

                top_text = ", ".join(top_strs) if top_strs else "none identified"
                bottom_text = " and ".join(bottom_strs) if bottom_strs else "none identified"

                if strategy == 'heatmap':
                    viz_note = (
                        f"The full correlation matrix is shown below ({n_features} features — "
                        f"readable at this scale)."
                    )
                else:
                    viz_note = (
                        f"With {n_features} numeric features, a full heatmap would be unreadable. "
                        f"The chart below shows the top {len(top_pairs)} most correlated pairs instead."
                    )

                story.append(Paragraph(
                    f"Among the {n_features} numeric features, the strongest correlations are: "
                    f"{top_text}. "
                    f"The least correlated pairs are {bottom_text} — these features carry "
                    f"largely independent information. {viz_note}",
                    self.styles['Narrative']
                ))
                story.append(Spacer(1, 0.1 * inch))

        # ── Correlation chart (heatmap or bar chart) ─────────────────────────
        if self.heatmap_plot and os.path.exists(self.heatmap_plot):
            try:
                strategy = self.corr_summary.get('strategy', 'heatmap') if self.corr_summary else 'heatmap'
                if strategy == 'bar_chart':
                    # Bar chart is wider/shorter
                    hm_img = Image(self.heatmap_plot, width=7.0 * inch, height=4.5 * inch)
                    caption = (
                        "Ranked correlation chart: blue bars = positive correlation, "
                        "red bars = negative correlation. Values near ±1.0 indicate "
                        "strong linear relationships; values near 0 indicate independence."
                    )
                else:
                    hm_img = Image(self.heatmap_plot, width=6.0 * inch, height=4.8 * inch)
                    caption = (
                        "Heatmap shows pairwise feature correlations. Strong correlations "
                        "(near ±1.0) may indicate redundant features or multicollinearity."
                    )
                hm_img.hAlign = 'CENTER'
                story.append(hm_img)
                story.append(Paragraph(caption, self.styles['Caption']))
                story.append(Spacer(1, 0.3 * inch))
            except Exception as e:
                logger.warning(f"Could not embed correlation chart: {e}")

        # Distribution plots
        limit = 3 if self.mode == 'brief' else 8
        valid_plots = [p for p in self.dist_plots[:limit] if os.path.exists(p)]
        if valid_plots:
            story.append(Paragraph("Feature Distributions", self.styles['SubsectionHeading']))
            for plot_path in valid_plots:
                try:
                    # Source is (10, 3.5), Ratio ~2.85.
                    # Page width ~8.27 - 0.6 = ~7.6 inch.
                    # Height should be 7.6 / 2.85 = ~2.66 inch.
                    dist_img = Image(plot_path, width=7.5 * inch, height=2.8 * inch)
                    dist_img.hAlign = 'CENTER'
                    story.append(dist_img)
                    story.append(Spacer(1, 0.15 * inch))
                except Exception:
                    pass

    # ==================================================================
    # SECTION: OUTLIER & INTERACTION ANALYSIS (Detailed mode only)
    # ==================================================================
    def _add_analysis_details(self, story):
        """Outlier detection and interaction results."""
        has_outliers = bool(self.outlier_results and self.outlier_results.get('summary'))
        has_interactions = bool(self.interaction_results and self.interaction_results.get('strong_interactions'))

        if not has_outliers and not has_interactions:
            return

        self._add_section_header(story, "Advanced Analysis")

        if has_outliers:
            story.append(Paragraph("Outlier Detection", self.styles['SubsectionHeading']))
            summary = self.outlier_results.get('summary', {})
            outlier_data = [['Detection Method', 'Outliers Found']]
            for method, count in summary.items():
                outlier_data.append([
                    Paragraph(str(method).replace('_', ' ').title(), self.styles['TableCell']),
                    Paragraph(str(count), ParagraphStyle('OC', parent=self.styles['TableCell'], alignment=TA_CENTER)),
                ])
            if len(outlier_data) > 1:
                ot = self._styled_table(outlier_data, [3.5 * inch, 2.5 * inch])
                story.append(ot)
            story.append(Spacer(1, 0.2 * inch))

        if has_interactions:
            story.append(Paragraph("Feature Interactions", self.styles['SubsectionHeading']))
            interactions = self.interaction_results.get('strong_interactions', [])
            for inter in interactions[:5]:
                if isinstance(inter, (tuple, list)) and len(inter) >= 2:
                    story.append(Paragraph(
                        f"  ➜  <b>{inter[0]}</b> ↔ <b>{inter[1]}</b>",
                        self.styles['Narrative']
                    ))



    # ==================================================================
    # HELPER: Feature Importance Bar Chart
    # ==================================================================
    def _generate_importance_bar_chart(self, top_feats, max_score) -> Optional[str]:
        """
        Generate a horizontal bar chart of feature importance scores.
        Returns path to a temp PNG file, or None if matplotlib unavailable.
        """
        if not MATPLOTLIB_AVAILABLE or not top_feats:
            return None
        try:
            feats = [f[:18] + '..' if len(f) > 20 else f for f, _ in top_feats]
            scores = [s for _, s in top_feats]
            n = len(feats)

            fig, ax = plt.subplots(figsize=(4.5, max(2.5, n * 0.38)))
            NAVY = '#1B3A5C'
            bars = ax.barh(range(n), scores, color=NAVY, alpha=0.85, height=0.6)
            ax.set_yticks(range(n))
            ax.set_yticklabels(feats[::-1] if feats else feats, fontsize=8)
            ax.invert_yaxis()
            ax.set_xlabel('Importance Score', fontsize=8)
            ax.set_xlim(0, max_score * 1.15)
            ax.spines['top'].set_visible(False)
            ax.spines['right'].set_visible(False)
            ax.tick_params(labelsize=7)
            # Value labels on bars
            for bar, score in zip(bars, scores):
                ax.text(bar.get_width() + max_score * 0.01, bar.get_y() + bar.get_height() / 2,
                        f'{score:.3f}', va='center', fontsize=7, color=NAVY)
            plt.tight_layout()

            tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
            fig.savefig(tmp.name, dpi=130, bbox_inches='tight',
                        facecolor='white', edgecolor='none')
            plt.close(fig)
            self._temp_files.append(tmp.name)
            return tmp.name
        except Exception as e:
            logger.warning(f"Importance bar chart generation failed: {e}")
            return None

    # ==================================================================
    # HEADER / FOOTER CALLBACKS
    # ==================================================================
    def _cover_page_callback(self, canvas_obj, doc):
        """Draw logo as a large low-opacity watermark on the cover page."""
        canvas_obj.saveState()
        w, h = A4
        if self.logo_path and os.path.exists(self.logo_path):
            try:
                # Draw large faded logo as background watermark
                logo_size = 4.5 * inch
                x = (w - logo_size) / 2
                y = (h - logo_size) / 2 - 0.5 * inch
                canvas_obj.saveState()
                canvas_obj.setFillAlpha(0.06)  # Very low opacity
                canvas_obj.drawImage(
                    self.logo_path,
                    x, y,
                    width=logo_size, height=logo_size,
                    preserveAspectRatio=True,
                    mask='auto'
                )
                canvas_obj.restoreState()
            except Exception:
                pass
        canvas_obj.restoreState()

    def _header_footer_callback(self, canvas, doc):
        """Draw header and footer on every page (callback for onLaterPages)."""
        canvas.saveState()
        w, h = A4
        
        # --- Header bar ---
        canvas.setFillColor(ReportColors.HEADER_BG)
        canvas.rect(0, h - 35, w, 35, fill=True, stroke=False)
        
        # Logo in header (small)
        if self.logo_path and os.path.exists(self.logo_path):
            try:
                canvas.drawImage(self.logo_path, 12, h - 32, width=26, height=26,
                               preserveAspectRatio=True, mask='auto')
            except Exception:
                pass
                
        # Title in header
        canvas.setFont('Helvetica-Bold', 9)
        canvas.setFillColor(ReportColors.TEXT_ON_DARK)
        canvas.drawString(45, h - 24, self.title)
        
        # Date in header
        canvas.setFont('Helvetica', 8)
        canvas.drawRightString(w - 15, h - 24, datetime.now().strftime("%B %d, %Y"))
        
        # --- Footer ---
        canvas.setFillColor(ReportColors.DIVIDER)
        canvas.line(50, 30, w - 50, 30)
        canvas.setFont('Helvetica', 8)
        canvas.setFillColor(ReportColors.TEXT_CAPTION)
        canvas.drawString(50, 18, "Generated by OctoLearn AutoML")
        canvas.drawRightString(w - 50, 18, f"Page {doc.page}")
        
        canvas.restoreState()

    # ==================================================================
    # GENERATE — Build the complete PDF
    # ==================================================================
    def generate(self, filename: Optional[str] = None) -> str:
        """
        Build and save the complete PDF report.

        Orchestrates all report sections in order. Each section is wrapped
        in an individual ``try/except`` block so that a failure in one section
        (e.g., malformed model benchmarks) never crashes the entire report.
        Failed sections are replaced with a graceful "Section unavailable" note.

        Parameters
        ----------
        filename : str, optional
            Output file path. If ``None``, a timestamped filename is generated
            automatically (e.g., ``octolearn_report_20260218_192700.pdf``).

        Returns
        -------
        str
            Absolute path to the generated PDF file.

        Raises
        ------
        RuntimeError
            If the PDF build itself fails (e.g., disk full, permission denied).

        Examples
        --------
        >>> pdf_path = generator.generate("my_report.pdf")
        >>> print(f"Report saved to: {pdf_path}")
        """
        if not filename:
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            filename = f"octolearn_report_{timestamp}.pdf"

        def _safe_section(fn, name):
            """Run a section builder, log and skip on any error."""
            try:
                fn()
            except Exception as exc:
                logger.warning(f"Report section '{name}' failed: {exc}")
                story.append(Spacer(1, 0.1 * inch))
                story.append(Paragraph(
                    f"[{name} — section unavailable due to an internal error]",
                    self.styles.get('Caption', self.styles['Normal'])
                ))

        try:
            doc = SimpleDocTemplate(
                filename,
                pagesize=A4,
                rightMargin=0.75 * inch, leftMargin=0.75 * inch,
                topMargin=0.75 * inch, bottomMargin=0.75 * inch,
                title=self.title,
                author=self.author
            )

            story = []

            # 1. Cover page (kept separate — has its own canvas callback)
            _safe_section(lambda: self._add_cover_page(story), "Cover Page")

            # 2. Recommendations (moved to top as executive summary)
            _safe_section(lambda: self._add_recommendations(story), "Recommendations")
            story.append(Spacer(1, 0.4 * inch))

            # 3. Data Story narrative
            _safe_section(lambda: self._add_data_story(story), "Data Story")
            story.append(Spacer(1, 0.4 * inch))

            # 4. Health Dashboard
            _safe_section(lambda: self._add_health_dashboard(story), "Health Dashboard")
            story.append(Spacer(1, 0.4 * inch))

            # 5. Before / After transformation journey
            _safe_section(lambda: self._add_before_after(story), "Before & After")
            story.append(Spacer(1, 0.4 * inch))

            # 6. Feature Intelligence
            _safe_section(lambda: self._add_feature_intelligence(story), "Feature Intelligence")
            story.append(Spacer(1, 0.4 * inch))

            # 7. Model Arena
            _safe_section(lambda: self._add_model_arena(story), "Model Arena")
            story.append(Spacer(1, 0.4 * inch))

            # 8. Advanced Analysis (detailed mode only)
            if self.mode == 'detailed':
                _safe_section(lambda: self._add_analysis_details(story), "Advanced Analysis")
                story.append(Spacer(1, 0.4 * inch))

            # 9. Visual Insights (correlation + distributions)
            _safe_section(lambda: self._add_visual_insights(story), "Visual Insights")

            # Build PDF with canvas callbacks
            doc.build(
                story,
                onFirstPage=self._cover_page_callback,   # Logo watermark on cover
                onLaterPages=self._header_footer_callback
            )
            logger.info(f"[OK] Report generated: {filename}")
            return filename

        except Exception as e:
            logger.error(f"[FAIL] PDF generation failed: {str(e)}")
            raise RuntimeError(f"PDF generation failed: {str(e)}")

        finally:
            # Clean up all temp files created during report generation
            for tmp_path in self._temp_files:
                try:
                    if os.path.exists(tmp_path):
                        os.unlink(tmp_path)
                except Exception:
                    pass
            self._temp_files.clear()



# ============================================================================
# CONVENIENCE FUNCTIONS
# ============================================================================

def create_brief_report(raw_profile: Any, clean_profile: Any, **kwargs) -> str:
    """Create a concise 5-7 page report."""
    gen = ReportGenerator(raw_profile=raw_profile, clean_profile=clean_profile, mode='brief', **kwargs)
    return gen.generate()


def create_detailed_report(raw_profile: Any, clean_profile: Any, **kwargs) -> str:
    """Create a comprehensive 15+ page report."""
    gen = ReportGenerator(raw_profile=raw_profile, clean_profile=clean_profile, mode='detailed', **kwargs)
    return gen.generate()