//! Batch-report presentation builder.

use std::cmp::Reverse;
use std::collections::HashMap;

use crate::audit::normalized::NormalizedFinding;
use crate::audit::{normalize, BatchReport, BrokenLinkSeverity, NormalizedReport};
use crate::i18n::I18n;
use crate::output::explanations::get_explanation;
use crate::output::report_model::*;
use crate::seo::profile::PageType;
use crate::util::truncate_url;
use crate::wcag::Severity;

use super::actions::{
    build_narrative_arc, derive_action_plan, derive_business_impact, derive_execution_priority,
    impact_score, score_to_priority, severity_to_priority,
};
use super::helpers::{build_batch_appendix, build_batch_verdict};
use super::seo::{
    average_page_semantic_score, derive_domain_topics, derive_topic_overlap_pairs,
    extract_page_topics, page_profile_optimization_note,
};

/// Build a complete presentation model from a batch audit report
pub fn build_batch_presentation(batch: &BatchReport) -> BatchPresentation {
    let i18n = I18n::new("de").expect("default locale must always load");
    build_batch_presentation_with_locale(batch, &i18n)
}

/// Locale-aware variant of [`build_batch_presentation`].
pub fn build_batch_presentation_with_locale(batch: &BatchReport, i18n: &I18n) -> BatchPresentation {
    // Normalize all reports early — needed for overall scores, risk, module averages
    let normalized_reports: Vec<crate::audit::normalized::NormalizedReport> = batch
        .reports
        .iter()
        .map(|r| normalize(r).normalized)
        .collect();
    let collected = collect_batch_finding_groups(&normalized_reports, i18n);
    // Deduplicate findings with the same title across rule sources; prefer
    // non-"unknown." rule_ids, merge occurrence counts.
    let mut seen_titles: std::collections::HashMap<String, usize> =
        std::collections::HashMap::new();
    let mut deduped: Vec<FindingGroup> = Vec::with_capacity(collected.len());
    for group in collected {
        let key = group.title.trim().to_lowercase();
        if let Some(&idx) = seen_titles.get(&key) {
            let existing = &mut deduped[idx];
            if existing.rule_id.starts_with("unknown.") && !group.rule_id.starts_with("unknown.") {
                let merged = existing.occurrence_count + group.occurrence_count;
                *existing = group;
                existing.occurrence_count = merged;
            } else {
                existing.occurrence_count += group.occurrence_count;
            }
        } else {
            seen_titles.insert(key, deduped.len());
            deduped.push(group);
        }
    }
    let mut top_issues = deduped;
    top_issues.sort_by_key(|b| std::cmp::Reverse(impact_score(b)));

    let issue_frequency: Vec<IssueFrequency> = top_issues
        .iter()
        .map(|g| IssueFrequency {
            problem: g.title.clone(),
            wcag: g.wcag_criterion.clone(),
            occurrences: g.occurrence_count,
            affected_urls: g.affected_urls.len(),
            priority: g.priority,
        })
        .collect();

    let interactive_summary = {
        use std::collections::HashMap as HMap;
        let total_pages_tested = normalized_reports
            .iter()
            .filter(|nr| nr.accessibility_journey.is_some() || !nr.interactive_findings.is_empty())
            .count();
        if total_pages_tested == 0 {
            None
        } else {
            let mut category_map: HMap<String, (usize, Severity)> = HMap::new();
            for nr in &normalized_reports {
                let mut seen_in_page: std::collections::HashSet<String> = Default::default();
                for f in &nr.interactive_findings {
                    let entry = category_map
                        .entry(f.category.clone())
                        .or_insert((0, Severity::Low));
                    if seen_in_page.insert(f.category.clone()) {
                        entry.0 += 1;
                    }
                    if f.severity > entry.1 {
                        entry.1 = f.severity;
                    }
                }
            }
            let mut categories: Vec<crate::output::report_model::InteractiveCategoryRow> =
                category_map
                    .into_iter()
                    .map(|(category, (affected_urls, max_severity))| {
                        crate::output::report_model::InteractiveCategoryRow {
                            category,
                            affected_urls,
                            max_severity,
                        }
                    })
                    .collect();
            categories.sort_by_key(|c| Reverse(c.affected_urls));
            let pages_with_issues = normalized_reports
                .iter()
                .filter(|nr| !nr.interactive_findings.is_empty())
                .count();
            let has_critical = normalized_reports.iter().any(|nr| {
                nr.interactive_findings
                    .iter()
                    .any(|f| f.severity == Severity::Critical)
            });
            Some(crate::output::report_model::InteractiveJourneySummary {
                total_pages_tested,
                pages_with_issues,
                categories,
                has_critical,
            })
        }
    };

    let action_plan = derive_action_plan(i18n, &top_issues);

    let mut url_ranking: Vec<UrlSummary> = batch
        .reports
        .iter()
        .zip(normalized_reports.iter())
        .map(|(r, nr)| {
            let critical_count = nr.severity_counts.critical + nr.severity_counts.high;
            UrlSummary {
                url: r.url.clone(),
                score: nr.score as f32,
                overall_score: nr.overall_score,
                grade: nr.grade.clone(),
                critical_violations: critical_count,
                total_violations: nr.severity_counts.total,
                passed: nr.score >= 70 && nr.severity_counts.critical == 0,
                priority: score_to_priority(nr.score as f32),
            }
        })
        .collect();
    url_ranking.sort_by(|a, b| {
        a.score
            .partial_cmp(&b.score)
            .unwrap_or(std::cmp::Ordering::Equal)
    });

    let url_details: Vec<CompactUrlSummary> = batch
        .reports
        .iter()
        .zip(normalized_reports.iter())
        .map(|(r, nr)| {
            let per_url_groups = normalized_finding_groups(i18n, nr);
            let mut sorted = per_url_groups;
            sorted.sort_by_key(|b| std::cmp::Reverse(impact_score(b)));
            let top_issue_titles: Vec<String> =
                sorted.iter().take(3).map(|g| g.title.clone()).collect();

            let module_scores = nr
                .module_scores
                .iter()
                .map(|m| (m.name.clone(), m.score))
                .collect();

            let topic_terms = extract_page_topics(r);

            CompactUrlSummary {
                url: r.url.clone(),
                score: nr.score as f32,
                grade: nr.grade.clone(),
                critical_violations: nr.severity_counts.critical + nr.severity_counts.high,
                total_violations: nr.severity_counts.total,
                page_type: r
                    .seo
                    .as_ref()
                    .and_then(|seo| seo.content_profile.as_ref())
                    .map(|profile| profile.page_classification.primary_type.label().to_string()),
                page_attributes: r
                    .seo
                    .as_ref()
                    .and_then(|seo| seo.content_profile.as_ref())
                    .map(|profile| profile.page_classification.attributes.clone())
                    .unwrap_or_default(),
                page_semantic_score: r
                    .seo
                    .as_ref()
                    .and_then(|seo| seo.content_profile.as_ref())
                    .map(|profile| average_page_semantic_score(&profile.page_classification)),
                biggest_lever: sorted
                    .first()
                    .map(|g| g.title.clone())
                    .or_else(|| {
                        r.seo.as_ref().and_then(|seo| {
                            seo.content_profile
                                .as_ref()
                                .map(|cp| page_profile_optimization_note(i18n.locale(), cp))
                        })
                    })
                    .unwrap_or_else(|| {
                        if i18n.locale() == "en" {
                            "Maintain results".to_string()
                        } else {
                            "Ergebnisse stabil halten".to_string()
                        }
                    }),
                topic_terms,
                top_issues: top_issue_titles,
                module_scores,
            }
        })
        .collect();

    let mut sorted_by_score: Vec<_> = batch
        .reports
        .iter()
        .zip(normalized_reports.iter())
        .collect();
    sorted_by_score.sort_by(|a, b| {
        (a.1.score as f32)
            .partial_cmp(&(b.1.score as f32))
            .unwrap_or(std::cmp::Ordering::Equal)
    });
    let worst_urls: Vec<(String, f32)> = sorted_by_score
        .iter()
        .take(3)
        .map(|(r, nr)| (truncate_url(&r.url, 60), nr.score as f32))
        .collect();
    let best_urls: Vec<(String, f32)> = sorted_by_score
        .iter()
        .rev()
        .take(3)
        .map(|(r, nr)| (truncate_url(&r.url, 60), nr.score as f32))
        .collect();

    let severity_distribution = {
        let critical = normalized_reports
            .iter()
            .map(|nr| nr.severity_counts.critical)
            .sum();
        let high = normalized_reports
            .iter()
            .map(|nr| nr.severity_counts.high)
            .sum();
        let medium = normalized_reports
            .iter()
            .map(|nr| nr.severity_counts.medium)
            .sum();
        let low = normalized_reports
            .iter()
            .map(|nr| nr.severity_counts.low)
            .sum();
        SeverityDistribution {
            critical,
            high,
            medium,
            low,
        }
    };

    let mut page_type_counts: HashMap<String, usize> = HashMap::new();
    let mut page_semantic_scores: Vec<(String, String, u32)> = Vec::new();
    let mut thin_pages = 0usize;
    let mut editorial_pages = 0usize;
    let mut marketing_pages = 0usize;
    for report in &batch.reports {
        if let Some(profile) = report
            .seo
            .as_ref()
            .and_then(|seo| seo.content_profile.as_ref())
        {
            let label = profile.page_classification.primary_type.label().to_string();
            *page_type_counts.entry(label).or_default() += 1;
            let semantic_score = average_page_semantic_score(&profile.page_classification);
            page_semantic_scores.push((
                report.url.clone(),
                profile.page_classification.primary_type.label().to_string(),
                semantic_score,
            ));
            match profile.page_classification.primary_type {
                PageType::ThinContent => thin_pages += 1,
                PageType::Editorial => editorial_pages += 1,
                PageType::MarketingLanding => marketing_pages += 1,
                _ => {}
            }
        }
    }
    let mut page_type_distribution: Vec<(String, usize, u32)> = page_type_counts
        .into_iter()
        .map(|(label, count)| {
            let pct = ((count as f64 / batch.summary.total_urls as f64) * 100.0).round() as u32;
            (label, count, pct)
        })
        .collect();
    page_type_distribution.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));

    let mut distribution_insights = Vec::new();
    if thin_pages > 0 && (thin_pages as f64 / batch.summary.total_urls as f64) >= 0.2 {
        distribution_insights.push(
            "Hoher Anteil an Thin-Content-Seiten: Das kann Informationswert und SEO-Potenzial begrenzen."
                .to_string(),
        );
    }
    if editorial_pages == 0 {
        distribution_insights.push(
            "Editoriale Inhaltsseiten fehlen: Wissensaufbau und Suchintentionen werden kaum bedient."
                .to_string(),
        );
    }
    if marketing_pages > 0 && (marketing_pages as f64 / batch.summary.total_urls as f64) >= 0.5 {
        distribution_insights.push(
            "Marketing- und Landingpages dominieren: Mehr strukturierter Tiefeninhalt würde die Domain ausbalancieren."
                .to_string(),
        );
    }
    if distribution_insights.is_empty() && !page_type_distribution.is_empty() {
        distribution_insights.push(
            "Die Seitentypen sind insgesamt ausgewogen verteilt, ohne klar dominierende Schwachmuster."
                .to_string(),
        );
    }

    page_semantic_scores.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
    let strongest_content_pages = page_semantic_scores.iter().take(5).cloned().collect();
    let weakest_content_pages = page_semantic_scores.iter().rev().take(5).cloned().collect();
    let top_topics = derive_domain_topics(&url_details);
    let overlap_pairs = derive_topic_overlap_pairs(&url_details);

    // Near-duplicate detection via SimHash on page text excerpts
    let dup_inputs: Vec<(String, String)> = batch
        .reports
        .iter()
        .filter_map(|r| {
            r.seo
                .as_ref()
                .filter(|seo| !seo.technical.text_excerpt.is_empty())
                .map(|seo| (r.url.clone(), seo.technical.text_excerpt.clone()))
        })
        .collect();

    let near_duplicates: Vec<(String, String, u8)> = if dup_inputs.len() >= 2 {
        crate::audit::duplicate::detect_near_duplicates(&dup_inputs, 80, 80)
            .into_iter()
            .take(10)
            .map(|p| (p.url_a, p.url_b, p.similarity))
            .collect()
    } else {
        Vec::new()
    };

    // Budget violations: aggregate across all pages
    let budget_summary: Vec<(String, String, usize, String)> = {
        use std::collections::HashMap;
        let mut map: HashMap<String, (String, usize, &str)> = HashMap::new();
        for r in &batch.reports {
            for v in &r.budget_violations {
                let entry = map
                    .entry(v.metric.clone())
                    .or_insert_with(|| (v.budget_label.clone(), 0, "Warning"));
                entry.1 += 1;
                if v.severity == crate::audit::budget::BudgetSeverity::Error {
                    entry.2 = "Error";
                }
            }
        }
        let mut rows: Vec<_> = map
            .into_iter()
            .map(|(metric, (budget, count, sev))| (metric, budget, count, sev.to_string()))
            .collect();
        rows.sort_by(|a, b| b.2.cmp(&a.2).then(a.0.cmp(&b.0)));
        rows
    };

    // Render-blocking: aggregate across all pages
    let render_blocking_summary: Vec<(String, String)> = {
        let pages_with_data: Vec<_> = batch
            .reports
            .iter()
            .filter_map(|r| {
                r.performance
                    .as_ref()
                    .and_then(|p| p.render_blocking.as_ref())
            })
            .collect();
        if pages_with_data.is_empty() {
            Vec::new()
        } else {
            let n = pages_with_data.len() as f64;
            let total_blocking: usize = pages_with_data
                .iter()
                .map(|rb| rb.blocking_scripts.len() + rb.blocking_css.len())
                .sum();
            let total_third_party_bytes: u64 =
                pages_with_data.iter().map(|rb| rb.third_party_bytes).sum();
            let pages_with_blocking = pages_with_data
                .iter()
                .filter(|rb| rb.has_blocking())
                .count();
            let en = i18n.locale() == "en";
            let (
                label_pages_analyzed,
                label_pages_blocking,
                label_blocking_total,
                label_third_party,
            ) = if en {
                (
                    "Pages analyzed",
                    "Pages with blocking",
                    "Blocking resources total",
                    "Third-party traffic total",
                )
            } else {
                (
                    "Seiten analysiert",
                    "Seiten mit Blocking",
                    "Blocking-Ressourcen gesamt",
                    "Third-Party-Traffic gesamt",
                )
            };
            let pages_count_value = if en {
                format!("{} of {}", pages_with_data.len(), batch.reports.len())
            } else {
                format!("{} von {}", pages_with_data.len(), batch.reports.len())
            };
            vec![
                (label_pages_analyzed.to_string(), pages_count_value),
                (
                    label_pages_blocking.to_string(),
                    format!(
                        "{} ({:.0}%)",
                        pages_with_blocking,
                        pages_with_blocking as f64 / n * 100.0
                    ),
                ),
                (label_blocking_total.to_string(), total_blocking.to_string()),
                (
                    label_third_party.to_string(),
                    format!("{:.1} KB", total_third_party_bytes as f64 / 1024.0),
                ),
            ]
        }
    };

    let crawl_links = batch
        .crawl_diagnostics
        .as_ref()
        .map(|crawl| CrawlLinkSummary {
            seed_url: crawl.seed_url.clone(),
            checked_internal_links: crawl.checked_internal_links,
            broken_internal_links: crawl
                .broken_internal_links
                .iter()
                .take(20)
                .map(|link| BrokenLinkRow {
                    source_url: link.source_url.clone(),
                    target_url: link.target_url.clone(),
                    status: match (link.status_code, link.error.as_deref()) {
                        (Some(code), _) => code.to_string(),
                        (None, Some(err)) => err.to_string(),
                        (None, None) => "Unbekannt".to_string(),
                    },
                    is_external: link.is_external,
                    severity: match link.severity {
                        BrokenLinkSeverity::High => "high".to_string(),
                        BrokenLinkSeverity::Medium => "medium".to_string(),
                        BrokenLinkSeverity::Low => "low".to_string(),
                    },
                    redirect_hops: link.redirect_hops,
                })
                .collect(),
            checked_external_links: crawl.checked_external_links,
            broken_external_links: crawl
                .broken_external_links
                .iter()
                .take(20)
                .map(|link| BrokenLinkRow {
                    source_url: link.source_url.clone(),
                    target_url: link.target_url.clone(),
                    status: match (link.status_code, link.error.as_deref()) {
                        (Some(code), _) => code.to_string(),
                        (None, Some(err)) => err.to_string(),
                        (None, None) => "Unbekannt".to_string(),
                    },
                    is_external: link.is_external,
                    severity: match link.severity {
                        BrokenLinkSeverity::High => "high".to_string(),
                        BrokenLinkSeverity::Medium => "medium".to_string(),
                        BrokenLinkSeverity::Low => "low".to_string(),
                    },
                    redirect_hops: link.redirect_hops,
                })
                .collect(),
            redirect_chains: crawl
                .redirect_chains
                .iter()
                .take(20)
                .map(|chain| RedirectChainRow {
                    source_url: chain.source_url.clone(),
                    target_url: chain.target_url.clone(),
                    final_url: chain.final_url.clone(),
                    hops: chain.hops,
                    is_external: chain.is_external,
                })
                .collect(),
        });

    // ── Aggregated module scores, overall score, risk ──────────────
    let average_overall_score = if normalized_reports.is_empty() {
        0
    } else {
        let sum: u32 = normalized_reports.iter().map(|n| n.overall_score).sum();
        (sum as f64 / normalized_reports.len() as f64).round() as u32
    };

    let average_score = batch.summary.average_score.round() as u32;
    let verdict_text = build_batch_verdict(i18n, batch);

    // Aggregate module averages
    let module_averages = {
        let mut module_sums: HashMap<String, (u32, usize)> = HashMap::new();
        for nr in &normalized_reports {
            for ms in &nr.module_scores {
                let entry = module_sums.entry(ms.name.clone()).or_insert((0, 0));
                entry.0 += ms.score;
                entry.1 += 1;
            }
        }
        let mut avgs: Vec<(String, u32)> = module_sums
            .into_iter()
            .map(|(name, (sum, count))| (name, (sum as f64 / count as f64).round() as u32))
            .collect();
        // Stable order: Accessibility first, then alphabetical
        avgs.sort_by(|a, b| {
            if a.0 == "Accessibility" {
                std::cmp::Ordering::Less
            } else if b.0 == "Accessibility" {
                std::cmp::Ordering::Greater
            } else {
                a.0.cmp(&b.0)
            }
        });
        avgs
    };

    // Active modules (from first report that has data)
    let active_modules: Vec<String> = module_averages.iter().map(|(n, _)| n.clone()).collect();

    // Schema type distribution across all pages
    let (schema_distribution, pages_without_schema) = {
        let mut type_counts: HashMap<String, usize> = HashMap::new();
        let mut without = 0usize;
        for report in &batch.reports {
            if let Some(seo) = &report.seo {
                if seo.structured_data.types.is_empty() {
                    without += 1;
                } else {
                    for schema_type in &seo.structured_data.types {
                        *type_counts.entry(format!("{:?}", schema_type)).or_insert(0) += 1;
                    }
                }
            }
        }
        let mut dist: Vec<(String, usize)> = type_counts.into_iter().collect();
        dist.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
        (dist, without)
    };

    // Batch risk level: escalate only when ≥20% of pages share that level.
    // This prevents a single outlier page from setting the domain-wide headline risk.
    // Critical is always surfaced regardless of page share.
    let (risk_level, risk_summary) = {
        use crate::audit::normalized::RiskLevel;
        let page_count = normalized_reports.len().max(1);
        let worst = {
            let mut counts = std::collections::HashMap::new();
            for r in normalized_reports.iter() {
                *counts.entry(r.risk.level).or_insert(0usize) += 1;
            }
            [RiskLevel::Critical, RiskLevel::High, RiskLevel::Medium]
                .iter()
                .copied()
                .find(|&lvl| {
                    let n = *counts.get(&lvl).unwrap_or(&0);
                    lvl == RiskLevel::Critical || n * 5 >= page_count
                })
                .unwrap_or(RiskLevel::Low)
        };
        let level_str = worst.label_localized(i18n);
        let en = i18n.locale() == "en";
        let summary = match (worst, en) {
            (RiskLevel::Low, true) => "The audited pages overall show a low accessibility risk.",
            (RiskLevel::Low, false) => "Die geprüften Seiten weisen insgesamt ein geringes Barrierefreiheits-Risiko auf.",
            (RiskLevel::Medium, true) => "Some pages show medium risk. Targeted improvements recommended.",
            (RiskLevel::Medium, false) => "Einzelne Seiten weisen mittleres Risiko auf. Gezielte Verbesserungen empfohlen.",
            (RiskLevel::High, true) => "Several pages show high risk. Timely remediation recommended, especially for WCAG Level A violations.",
            (RiskLevel::High, false) => "Mehrere Seiten haben hohes Risiko. Zeitnahe Behebung empfohlen, besonders bei WCAG-Level-A-Verstößen.",
            (RiskLevel::Critical, true) => "Critical risk across multiple pages. WCAG Level A violations detected automatically — immediate action recommended; manual review required for a defensible legal classification.",
            (RiskLevel::Critical, false) => "Kritisches Risiko über mehrere Seiten. WCAG-Level-A-Verstöße automatisiert erkannt — sofortige Maßnahmen empfohlen, manuelle Prüfung für belastbare rechtliche Einordnung nötig.",
        };
        (level_str, summary.to_string())
    };

    // Domain from first URL
    let domain = batch
        .reports
        .first()
        .map(|r| {
            url::Url::parse(&r.url)
                .ok()
                .and_then(|u| u.host_str().map(|h| h.to_string()))
                .unwrap_or_else(|| r.url.clone())
        })
        .unwrap_or_default();

    // Certificate and grade are based on the primary WCAG/accessibility score.
    let certificate = match average_score {
        95.. => "SEHR GUT",
        85.. => "GUT",
        70.. => "SOLIDE",
        50.. => "AUSBAUFÄHIG",
        _ => "UNGENÜGEND",
    }
    .to_string();

    let grade = match average_score {
        95.. => "A+",
        90.. => "A",
        85.. => "B+",
        80.. => "B",
        70.. => "C",
        60.. => "D",
        _ => "F",
    }
    .to_string();

    let en = i18n.locale() == "en";
    BatchPresentation {
        cover: CoverData {
            title: if en {
                "Web Accessibility Batch Audit Report".to_string()
            } else {
                "Barrierefreiheits-Batch-Audit-Report".to_string()
            },
            url: if en {
                format!("{} URLs audited", batch.summary.total_urls)
            } else {
                format!("{} URLs geprüft", batch.summary.total_urls)
            },
            date: if en {
                chrono::Utc::now().format("%Y-%m-%d").to_string()
            } else {
                chrono::Utc::now().format("%d.%m.%Y").to_string()
            },
            version: env!("CARGO_PKG_VERSION").to_string(),
        },
        portfolio_summary: PortfolioSummary {
            total_urls: batch.summary.total_urls,
            passed: batch.summary.passed,
            failed: batch.summary.failed,
            average_score: batch.summary.average_score,
            average_overall_score,
            total_violations: batch.summary.total_violations,
            duration_ms: batch.total_duration_ms,
            verdict_text,
            worst_urls,
            best_urls,
            severity_distribution,
            risk_level,
            risk_summary,
            module_averages,
            active_modules,
            domain,
            certificate,
            grade,
            page_type_distribution,
            distribution_insights,
            strongest_content_pages,
            weakest_content_pages,
            top_topics,
            overlap_pairs,
            near_duplicates,
            crawl_links,
            budget_summary,
            render_blocking_summary,
            schema_distribution,
            pages_without_schema,
        },
        top_issues: top_issues.into_iter().take(10).collect(),
        issue_frequency,
        action_plan,
        url_ranking,
        url_details,
        url_matrix: build_url_matrix(batch),
        appendix: build_batch_appendix(batch),
        interactive_summary,
    }
}

// ─── URL matrix ─────────────────────────────────────────────────────────────

fn build_url_matrix(batch: &BatchReport) -> Vec<UrlMatrixRow> {
    // Build inbound link map: path → count of pages that link here
    let mut inbound: HashMap<String, usize> = HashMap::new();
    for report in &batch.reports {
        if let Some(seo) = &report.seo {
            for target in &seo.technical.internal_link_targets {
                *inbound.entry(target.clone()).or_insert(0) += 1;
            }
        }
    }

    batch
        .reports
        .iter()
        .enumerate()
        .map(|(i, r)| {
            let title = r.seo.as_ref().and_then(|seo| seo.meta.title.clone());
            let word_count = r
                .seo
                .as_ref()
                .map(|seo| seo.technical.word_count)
                .unwrap_or(0);
            let outbound = r
                .seo
                .as_ref()
                .map(|seo| seo.technical.internal_links + seo.technical.external_links)
                .unwrap_or(0);
            let path = url_path(&r.url);
            let inbound_count = inbound.get(&path).copied().unwrap_or(0);

            UrlMatrixRow {
                rank: i + 1,
                url: r.url.clone(),
                title,
                inbound_links: inbound_count,
                outbound_links: outbound,
                word_count,
            }
        })
        .collect()
}

fn url_path(url: &str) -> String {
    url::Url::parse(url)
        .map(|u| u.path().to_string())
        .unwrap_or_else(|_| url.to_string())
}

// ─── Internal batch helpers ──────────────────────────────────────────────────

#[derive(Clone)]
struct NormalizedFindingAccumulator {
    finding: NormalizedFinding,
    severity: Severity,
    count: usize,
    urls: Vec<String>,
}

fn collect_batch_finding_groups(
    normalized_reports: &[NormalizedReport],
    i18n: &I18n,
) -> Vec<FindingGroup> {
    let mut groups: HashMap<String, NormalizedFindingAccumulator> = HashMap::new();
    for report in normalized_reports {
        for finding in &report.findings {
            let base_severity = crate::taxonomy::rules::RULES
                .iter()
                .find(|r| r.id == finding.rule_id)
                .map(|r| r.severity)
                .unwrap_or(finding.severity);
            let entry = groups
                .entry(finding.aggregation_key.clone())
                .or_insert_with(|| NormalizedFindingAccumulator {
                    finding: finding.clone(),
                    severity: base_severity,
                    count: 0,
                    urls: Vec::new(),
                });
            entry.count += finding.occurrence_count;
            if !entry.urls.contains(&report.url) {
                entry.urls.push(report.url.clone());
            }
        }
    }

    groups
        .values()
        .map(|acc| finding_group_from_normalized(i18n, acc))
        .collect()
}

fn normalized_finding_groups(i18n: &I18n, normalized: &NormalizedReport) -> Vec<FindingGroup> {
    normalized
        .findings
        .iter()
        .map(|finding| {
            finding_group_from_normalized(
                i18n,
                &NormalizedFindingAccumulator {
                    finding: finding.clone(),
                    severity: finding.severity,
                    count: finding.occurrence_count,
                    urls: vec![normalized.url.clone()],
                },
            )
        })
        .collect()
}

fn finding_group_from_normalized(i18n: &I18n, acc: &NormalizedFindingAccumulator) -> FindingGroup {
    let locale = i18n.locale();
    let finding = &acc.finding;
    let explanation =
        get_explanation(&finding.rule_id).or_else(|| get_explanation(&finding.wcag_criterion));
    let dimension_label = finding.dimension.as_str();
    let (
        title,
        customer_desc,
        user_impact_text,
        business_impact,
        typical_cause,
        recommendation,
        technical_note,
        role,
        effort,
        execution_priority,
    ) = if let Some(expl) = explanation {
        (
            expl.customer_title_for(locale).to_string(),
            expl.customer_description_for(locale).to_string(),
            expl.user_impact_for(locale).to_string(),
            derive_business_impact(
                i18n,
                expl.user_impact_for(locale),
                dimension_label,
                acc.severity,
                Some(finding.subcategory.as_str()),
                acc.count,
            ),
            expl.typical_cause_for(locale).to_string(),
            expl.recommendation_for(locale).to_string(),
            expl.technical_note_for(locale).to_string(),
            expl.responsible_role,
            expl.effort_estimate,
            derive_execution_priority(acc.severity, expl.effort_estimate, dimension_label),
        )
    } else {
        (
            finding.title.clone(),
            finding.description.clone(),
            finding.user_impact.clone(),
            derive_business_impact(
                i18n,
                &finding.user_impact,
                dimension_label,
                acc.severity,
                Some(finding.subcategory.as_str()),
                acc.count,
            ),
            String::new(),
            finding
                .occurrences
                .iter()
                .find_map(|o| o.fix_suggestion.clone())
                .unwrap_or_default(),
            finding.technical_impact.clone(),
            Role::Development,
            Effort::Medium,
            derive_execution_priority(acc.severity, Effort::Medium, dimension_label),
        )
    };
    let examples = explanation.map(|e| e.examples()).unwrap_or_default();
    let location_hints = finding
        .occurrences
        .iter()
        .filter_map(|occ| {
            occ.selector
                .clone()
                .or_else(|| Some(format!("AX-Node {}", occ.node_id)))
        })
        .take(5)
        .collect();
    let representative_occurrences = finding
        .occurrences
        .iter()
        .take(3)
        .map(representative_occurrence_from_normalized)
        .collect();

    let narrative = build_narrative_arc(
        i18n,
        acc.count,
        acc.severity,
        dimension_label,
        &customer_desc,
        &user_impact_text,
        &business_impact,
        &typical_cause,
        &recommendation,
        effort,
        role,
    );

    FindingGroup {
        title,
        rule_id: finding.rule_id.clone(),
        wcag_criterion: finding.wcag_criterion.clone(),
        wcag_level: finding.wcag_level.clone(),
        help_url: finding.help_url.clone(),
        dimension: Some(finding.dimension.clone()),
        subcategory: Some(finding.subcategory.clone()),
        issue_class: Some(finding.issue_class.clone()),
        severity: acc.severity,
        priority: severity_to_priority(acc.severity),
        customer_description: customer_desc,
        user_impact: user_impact_text,
        business_impact,
        typical_cause,
        recommendation,
        technical_note,
        confidence: finding.confidence.clone(),
        false_positive_risk: finding.false_positive_risk.clone(),
        verification: finding.verification.clone(),
        complexity: finding.complexity.clone(),
        complexity_reason: finding.complexity_reason.clone(),
        expected_impact: finding.expected_impact.clone(),
        bfsg_relevance: finding.bfsg_relevance.clone(),
        remediation_priority: finding.remediation_priority.clone(),
        occurrence_count: acc.count,
        affected_urls: acc.urls.clone(),
        affected_elements: acc.count,
        additional_occurrences: acc.count,
        pattern_clusters: Vec::new(),
        location_hints,
        representative_occurrences,
        responsible_role: role,
        effort,
        execution_priority,
        examples,
        structural_cause: if acc.count >= 10 {
            if locale == "en" {
                Some(format!(
                    "Root cause: 1 component issue producing {} occurrences. \
                     This is likely a shared template or component — fixing it once \
                     eliminates all occurrences simultaneously.",
                    acc.count
                ))
            } else {
                Some(format!(
                    "Root Cause: 1 Komponentenproblem erzeugt {} Vorkommen. \
                     Wahrscheinlich ein gemeinsam genutztes Template oder eine Komponente — \
                     ein einmaliger Fix behebt alle Vorkommen gleichzeitig.",
                    acc.count
                ))
            }
        } else if acc.count >= 5 {
            if locale == "en" {
                Some(format!(
                    "This issue appears on {} elements — possibly a shared component or template.",
                    acc.count
                ))
            } else {
                Some(format!(
                    "Dieses Problem tritt bei {} Elementen auf — möglicherweise eine gemeinsam genutzte Komponente oder ein Template.",
                    acc.count
                ))
            }
        } else {
            None
        },
        is_component_issue: acc.count >= 10,
        criticality_tier: crate::output::report_model::classify_criticality_tier(
            &finding.category,
            &finding.wcag_level,
        ),
        narrative,
    }
}

fn representative_occurrence_from_normalized(
    occurrence: &crate::audit::normalized::OccurrenceDetail,
) -> RepresentativeOccurrence {
    RepresentativeOccurrence {
        selector: occurrence
            .selector
            .clone()
            .unwrap_or_else(|| format!("AX-Node {}", occurrence.node_id)),
        node_id: occurrence.node_id.clone(),
        message: occurrence.message.clone(),
        html_snippet: occurrence.html_snippet.clone(),
        suggested_code: occurrence.suggested_code.clone(),
    }
}
