use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GuideData {
    pub name: String,
    pub gid: String,
    pub effect: String,
    pub recommended_level: Option<u32>,
    pub combat_legend: Vec<CombatType>,
    pub resources: Vec<Resource>,
    pub sections: Vec<Section>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CombatType {
    pub name: String,
    pub column: usize,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Resource {
    pub quantity: u32,
    pub name: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Section {
    pub name: String,
    pub items: Vec<SectionItem>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum SectionItem {
    Quest(QuestItem),
    Instruction(InstructionItem),
    Group(GroupItem),
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct QuestItem {
    pub name: String,
    pub completed: bool,
    pub combat_indicators: Vec<CombatIndicator>,
    pub note: Option<String>,
    pub url: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct CombatIndicator {
    pub combat_type: String,
    pub count: String,
    #[serde(default)]
    pub label: Option<String>,
    #[serde(default)]
    pub evitable: bool,
}

/// Extrait les indicateurs de combat depuis la section "À prévoir" d'une page de quête DPLN.
///
/// La section est identifiée par un `<strong>` contenant "À prévoir" dans un `<div class="paragraph">`.
/// Le fragment HTML entre ce marqueur et le prochain `<strong>` est re-parsé pour extraire les `<li>`.
pub fn extract_a_prevoir(html: &str) -> Vec<CombatIndicator> {
    use scraper::{Html, Selector};
    use regex::Regex;

    // Sélecteur sur tous les paragraphes potentiels du header de quête
    let para_sel = Selector::parse("div.paragraph").unwrap();
    let document = Html::parse_document(html);

    // Regex pour détecter "À prévoir" dans le inner_html (gère les entités HTML)
    let re_header = Regex::new(
        r"(?i)<strong[^>]*>\s*(?:&Agrave;|À)\s*pr(?:&eacute;|é)voir\s*:?\s*</strong>"
    ).unwrap();

    // Regex pour extraire le count en début de texte : "2 x ..." ou "Des ..."
    let re_count = Regex::new(r"(?i)^(\d+)\s*[xX]\s*").unwrap();

    for para in document.select(&para_sel) {
        let inner = para.inner_html();

        if !re_header.is_match(&inner) {
            continue;
        }

        // Coupe le fragment après le marqueur "À prévoir" jusqu'au prochain <strong> (champ suivant)
        let after_marker = re_header.splitn(&inner, 2).nth(1).unwrap_or("");
        // Tronque au prochain <strong> pour ne pas déborder sur un autre champ
        let fragment_html = match after_marker.find("<strong") {
            Some(pos) => &after_marker[..pos],
            None => after_marker,
        };

        let fragment = Html::parse_fragment(fragment_html);
        let li_sel = Selector::parse("li").unwrap();

        let mut indicators = Vec::new();

        for li in fragment.select(&li_sel) {
            let raw_text: String = li.text().collect::<Vec<_>>().join(" ");
            let text = raw_text.trim().to_string();
            if text.is_empty() {
                continue;
            }

            let lower = text.to_lowercase();
            let evitable = lower.contains("évitable") || lower.contains("evitable");

            // Extraction du count
            let count = if lower.starts_with("des ") {
                "?".to_string()
            } else if let Some(cap) = re_count.captures(&text) {
                cap[1].to_string()
            } else {
                "1".to_string()
            };

            // Texte sans le préfixe "N x "
            let text_without_count = re_count.replace(&text, "").to_string();
            let lower_no_count = text_without_count.to_lowercase();

            // Mapping vers combat_type
            let (combat_type, label) = if lower_no_count.contains("donjon") {
                // Extrait le nom du donjon : tout sauf le mot "donjon" et ce qui suit
                let label = extract_donjon_label(&text_without_count);
                ("donjon".to_string(), Some(label))
            } else if lower_no_count.contains("combat") {
                if lower_no_count.contains("tactique") {
                    ("combat_tactique".to_string(), None)
                } else if lower_no_count.contains("vague") {
                    ("combat_vagues".to_string(), None)
                } else if lower_no_count.contains("aléatoire") || lower_no_count.contains("aleatoire") {
                    ("combat_aleatoire".to_string(), None)
                } else if lower_no_count.contains("monstre") {
                    ("combat_zone".to_string(), None)
                } else if lower_no_count.contains("seul") {
                    ("solo".to_string(), None)
                } else if lower_no_count.contains("groupe") || lower_no_count.contains("réalisable") || lower_no_count.contains("realisable") {
                    ("groupe".to_string(), None)
                } else {
                    // Combat sans précision supplémentaire
                    ("groupe".to_string(), None)
                }
            } else if lower_no_count.contains("aller à") || lower_no_count.contains("aller a") {
                ("deplacement".to_string(), None)
            } else {
                // Fallback : item nommé
                let label = text_without_count
                    .trim()
                    .trim_end_matches('.')
                    .trim()
                    .to_string();
                ("item".to_string(), Some(label))
            };

            indicators.push(CombatIndicator {
                combat_type,
                count,
                label,
                evitable,
            });
        }

        // Premier paragraphe "À prévoir" trouvé — on retourne immédiatement
        return indicators;
    }

    Vec::new()
}

/// Extrait le nom du donjon depuis un texte de type "Donjon Antre du Dragon Cochon."
/// Supprime le mot "Donjon" en début (case-insensitive), nettoie la ponctuation finale.
fn extract_donjon_label(text: &str) -> String {
    let re = regex::Regex::new(r"(?i)^donjon\s*").unwrap();
    let without = re.replace(text, "");
    // Retire les suffixes parenthétiques courants : "(réalisable en groupe)."
    let trimmed = if let Some(paren) = without.find('(') {
        without[..paren].trim().to_string()
    } else {
        without.trim().trim_end_matches('.').trim().to_string()
    };
    trimmed
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct InstructionItem {
    pub text: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GroupItem {
    pub note: Option<String>,
    pub quests: Vec<QuestItem>,
}

const SHEET_ID: &str = "1uL7svJ0E0MjhqHVLU7O4Q8v7iGwPd4bsI9qV-Pdhdds";

pub static TABS: &[(&str, &str)] = &[
    ("474870200",  "Dofawa"),
    ("743703882",  "Dofus Argenté"),
    ("103963898",  "Dofus Cawotte"),
    ("1075294690", "Dokoko"),
    ("1567240526", "Dofus des Veilleurs"),
    ("1011508069", "Dofus Emeraude"),
    ("2045137654", "Dofus Pourpre"),
    ("1967508888", "Domakuro"),
    ("1382359191", "Dorigami"),
    ("1413546794", "Dofus Turquoise"),
    ("1641656252", "Dofus des Glaçes"),
    ("953522228",  "Dofus Abyssal"),
    ("818597042",  "Dofoozbz"),
    ("1021129660", "Dofus Nébuleux"),
    ("595670723",  "Dofus Vulbis"),
    ("544349966",  "Dofus Tacheté"),
    ("1150302145", "Dofus Forgelave"),
    ("882278553",  "Dofus Ebène"),
    ("200570588",  "Dofus Ivoire"),
    ("1209269839", "Dofus Ocre"),
    ("462784268",  "Dofus Argenté Scintillant"),
    ("1543573905", "Dofus Cauchemar"),
    ("1007491889", "Dom de Pin"),
    ("1047555165", "Dofus Sylvestre"),
    ("2105601828", "Dofus Cacao"),
    ("474510463",  "Dokille"),
    ("62476099",   "Dolmanax"),
    ("1873654554", "Dotruche"),
    ("360188709",  "Dofus Kaliptus"),
];

fn make_client() -> Result<reqwest::blocking::Client, String> {
    reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .build()
        .map_err(|e| e.to_string())
}

pub fn fetch_csv(gid: &str) -> Result<String, String> {
    let url = format!(
        "https://docs.google.com/spreadsheets/d/{}/export?format=csv&gid={}",
        SHEET_ID, gid
    );
    make_client()?
        .get(&url)
        .send()
        .map_err(|e| e.to_string())?
        .text()
        .map_err(|e| e.to_string())
}

/// Fetch the sheet edit page HTML and extract quest_name → url pairs.
/// Pattern in Google Sheets JS: LS{url}]]LS{name}]]F
pub fn fetch_quest_links(gid: &str) -> std::collections::HashMap<String, String> {
    let url = format!(
        "https://docs.google.com/spreadsheets/d/{}/edit?gid={}",
        SHEET_ID, gid
    );
    let html = match make_client().and_then(|c| c.get(&url).send().map_err(|e| e.to_string())).and_then(|r| r.text().map_err(|e| e.to_string())) {
        Ok(h) => h,
        Err(_) => return std::collections::HashMap::new(),
    };

    let mut map = std::collections::HashMap::new();
    // Regex-free parsing: find all "LS{url}]]LS{name}]]F" occurrences
    let marker = "LS";
    let end_marker = "]]LS";
    let end_f = "]]F";
    let mut pos = 0;
    while let Some(start) = html[pos..].find("LShttps://www.dofuspourlesnoobs.com/") {
        let abs = pos + start + 2; // skip "LS"
        // Find end of URL
        if let Some(url_end) = html[abs..].find("]]LS") {
            let quest_url = html[abs..abs + url_end].to_string();
            let name_start = abs + url_end + 4; // skip "]]LS"
            if let Some(name_end) = html[name_start..].find("]]F") {
                let raw_name = &html[name_start..name_start + name_end];
                // Decode \\u00XX unicode escapes
                let name = decode_unicode_escapes(raw_name);
                map.entry(name).or_insert(quest_url);
            }
            pos = abs + url_end + 4;
        } else {
            break;
        }
    }
    let _ = (marker, end_marker, end_f);
    map
}

fn slugify(name: &str) -> String {
    let mut slug = String::with_capacity(name.len());
    for c in name.chars() {
        if c.is_ascii_alphanumeric() {
            slug.push(c.to_ascii_lowercase());
        } else {
            // apostrophes, spaces, accents, punctuation all become hyphens
            if !slug.ends_with('-') {
                slug.push('-');
            }
        }
    }
    let slug = slug.trim_matches('-').to_string();
    format!("https://www.dofuspourlesnoobs.com/{}.html", slug)
}

fn decode_unicode_escapes(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '\\' {
            match chars.next() {
                Some('u') => {
                    let hex: String = chars.by_ref().take(4).collect();
                    if let Ok(n) = u32::from_str_radix(&hex, 16) {
                        if let Some(decoded) = char::from_u32(n) {
                            result.push(decoded);
                            continue;
                        }
                    }
                    result.push_str("\\u");
                    result.push_str(&hex);
                }
                // Google Sheets double-escapes: \\u0027 → '
                Some('\\') => {
                    if chars.peek() == Some(&'u') {
                        chars.next(); // consume 'u'
                        let hex: String = chars.by_ref().take(4).collect();
                        if let Ok(n) = u32::from_str_radix(&hex, 16) {
                            if let Some(decoded) = char::from_u32(n) {
                                result.push(decoded);
                                continue;
                            }
                        }
                        result.push_str("\\u");
                        result.push_str(&hex);
                    } else {
                        result.push('\\');
                    }
                }
                Some(other) => { result.push('\\'); result.push(other); }
                None => result.push('\\'),
            }
        } else {
            result.push(c);
        }
    }
    result
}

fn get_cell(row: &[String], col: usize) -> &str {
    row.get(col).map(|s| s.trim()).unwrap_or("")
}

fn find_checkbox_col(row: &[String]) -> Option<(usize, bool)> {
    for (i, cell) in row.iter().enumerate() {
        let v = cell.trim().to_uppercase();
        if v == "FALSE" || v == "TRUE" {
            return Some((i, v == "TRUE"));
        }
    }
    None
}

fn parse_level_from_cell(cell: &str) -> Option<u32> {
    // Only parse from the specific cell containing "niveau recommandé", not the full row
    let lower = cell.to_lowercase();
    if lower.contains("niveau recommandé") || lower.contains("niveau recommande") {
        // Extract only the number immediately after the colon/space
        if let Some(colon) = lower.find(':') {
            let after = lower[colon + 1..].trim_start();
            let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
            return digits.parse().ok();
        }
    }
    None
}

fn is_resource_row(row: &[String]) -> Option<(u32, String)> {
    // Resources live in the right portion of the sheet (cols 36+)
    // Pattern: quantity in some col, empty cols, then name
    for start_col in 36..row.len().saturating_sub(4) {
        let qty_str = row.get(start_col).map(|s| s.trim()).unwrap_or("");
        if qty_str.is_empty() { continue; }
        if let Ok(qty) = qty_str.parse::<u32>() {
            // Look for the name within the next few cols
            for name_col in (start_col + 1)..=(start_col + 5).min(row.len().saturating_sub(1)) {
                let name = row.get(name_col).map(|s| s.trim()).unwrap_or("");
                if !name.is_empty() && !name.to_uppercase().contains("KAMAS") || !name.is_empty() {
                    // Check that cols between qty and name are empty
                    let all_empty = ((start_col + 1)..name_col)
                        .all(|c| row.get(c).map(|s| s.trim()).unwrap_or("").is_empty());
                    if all_empty {
                        return Some((qty, name.to_string()));
                    }
                }
            }
        }
    }
    None
}

fn parse_combat_indicators(_row: &[String], _legend: &[CombatType], _checkbox_col: usize) -> Vec<CombatIndicator> {
    Vec::new()
}

fn extract_note(row: &[String], name_col: usize, legend: &[CombatType]) -> Option<String> {
    use regex::Regex;
    // Matches any combat-indicator cell: optional arrow prefix, optional "x", digits, optional suffix
    // Covers: "→x3", "→ x5 (Aléatoire)", "->x1 (évitable)", "3", "x2", etc.
    static RE_COMBAT_CELL: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
    let re = RE_COMBAT_CELL.get_or_init(|| {
        Regex::new(r"(?i)^(?:→+|-+>)\s*[x×]?\s*\d|^[x×]?\s*\d+$").unwrap()
    });

    let combat_cols: std::collections::HashSet<usize> = legend.iter().map(|c| c.column).collect();
    let max_search = row.len().min(36);
    for col in (name_col + 1)..max_search {
        let cell = get_cell(row, col).trim();
        if cell.is_empty() || combat_cols.contains(&col) {
            continue;
        }
        if cell.eq_ignore_ascii_case("false") || cell.eq_ignore_ascii_case("true") {
            continue;
        }
        if re.is_match(cell) {
            continue;
        }
        return Some(cell.to_string());
    }
    None
}

pub fn parse_guide(gid: &str, name: &str, csv: &str) -> GuideData {
    parse_guide_with_links(gid, name, csv, &std::collections::HashMap::new())
}

#[cfg(test)]
mod tests {
    use super::*;

    // --- Helpers ---------------------------------------------------------------

    /// Enveloppe un contenu dans un `<div class="paragraph">` complet pour simuler
    /// la structure réelle de dofuspourlesnoobs.com.
    fn para(inner: &str) -> String {
        format!(r#"<html><body><div class="paragraph">{}</div></body></html>"#, inner)
    }

    // --- Tests -----------------------------------------------------------------

    /// Cas 1 : aucun `<div class="paragraph">` contenant "À prévoir" → Vec vide.
    #[test]
    fn test_section_absente() {
        let html = r#"<html><body><p>Rien ici</p></body></html>"#;
        assert_eq!(extract_a_prevoir(html), vec![]);
    }

    /// Cas 2 : section présente mais `<ul>` vide → Vec vide.
    #[test]
    fn test_section_vide() {
        let html = para(
            r#"<strong>À prévoir :</strong>
            <ul></ul>
            <strong>Prochaine section</strong>"#,
        );
        assert_eq!(extract_a_prevoir(&html), vec![]);
    }

    /// Cas 3 : combat solo — `1 x combat seul.`
    #[test]
    fn test_combat_solo() {
        let html = para(
            r#"<strong>À prévoir :</strong>
            <ul>
              <li>1 x combat seul.</li>
            </ul>"#,
        );
        let result = extract_a_prevoir(&html);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].combat_type, "solo");
        assert_eq!(result[0].count, "1");
        assert_eq!(result[0].label, None);
        assert!(!result[0].evitable);
    }

    /// Cas 4 : combat groupe — `2 x combats (réalisable en groupe).`
    #[test]
    fn test_combat_groupe() {
        let html = para(
            r#"<strong>À prévoir :</strong>
            <ul>
              <li>2 x combats (réalisable en groupe).</li>
            </ul>"#,
        );
        let result = extract_a_prevoir(&html);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].combat_type, "groupe");
        assert_eq!(result[0].count, "2");
        assert!(!result[0].evitable);
    }

    /// Cas 5 : donjon avec label — `1 x Donjon Antre du Dragon Cochon.`
    #[test]
    fn test_donjon_avec_label() {
        let html = para(
            r#"<strong>À prévoir :</strong>
            <ul>
              <li>1 x Donjon Antre du Dragon Cochon.</li>
            </ul>"#,
        );
        let result = extract_a_prevoir(&html);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].combat_type, "donjon");
        assert_eq!(result[0].count, "1");
        assert_eq!(result[0].label, Some("Antre du Dragon Cochon".to_string()));
        assert!(!result[0].evitable);
    }

    /// Cas 6 : item nommé — `1 x Parchemin de Frigost.`
    #[test]
    fn test_item_nomme() {
        let html = para(
            r#"<strong>À prévoir :</strong>
            <ul>
              <li>1 x Parchemin de Frigost.</li>
            </ul>"#,
        );
        let result = extract_a_prevoir(&html);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].combat_type, "item");
        assert_eq!(result[0].count, "1");
        assert_eq!(result[0].label, Some("Parchemin de Frigost".to_string()));
        assert!(!result[0].evitable);
    }

    /// Cas 7 : combat évitable — `1 x combat seul (évitable).`
    #[test]
    fn test_combat_evitable() {
        let html = para(
            r#"<strong>À prévoir :</strong>
            <ul>
              <li>1 x combat seul (évitable).</li>
            </ul>"#,
        );
        let result = extract_a_prevoir(&html);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].combat_type, "solo");
        assert!(result[0].evitable);
    }

    /// Cas 8 : quantité "Des" — `Des combats contre des monstres.` → count "?", type "combat_zone"
    #[test]
    fn test_quantite_des() {
        let html = para(
            r#"<strong>À prévoir :</strong>
            <ul>
              <li>Des combats contre des monstres.</li>
            </ul>"#,
        );
        let result = extract_a_prevoir(&html);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].count, "?");
        assert_eq!(result[0].combat_type, "combat_zone");
    }

    /// Cas 9 : section "À savoir" présente au lieu de "À prévoir" → Vec vide.
    #[test]
    fn test_a_savoir_ne_matche_pas() {
        let html = para(
            r#"<strong>À savoir :</strong>
            <ul>
              <li>1 x combat seul.</li>
            </ul>"#,
        );
        assert_eq!(extract_a_prevoir(&html), vec![]);
    }

    /// Cas 10 : plusieurs items dans une même section — vérifier l'ordre et le count.
    #[test]
    fn test_multiple_items() {
        let html = para(
            r#"<strong>À prévoir :</strong>
            <ul>
              <li>1 x combat seul.</li>
              <li>2 x combats (réalisable en groupe).</li>
              <li>1 x Donjon Antre du Dragon Cochon.</li>
            </ul>"#,
        );
        let result = extract_a_prevoir(&html);
        assert_eq!(result.len(), 3);

        assert_eq!(result[0].combat_type, "solo");
        assert_eq!(result[0].count, "1");

        assert_eq!(result[1].combat_type, "groupe");
        assert_eq!(result[1].count, "2");

        assert_eq!(result[2].combat_type, "donjon");
        assert_eq!(result[2].count, "1");
        assert_eq!(result[2].label, Some("Antre du Dragon Cochon".to_string()));
    }

    /// Cas 11 : entités HTML dans inner_html() — `&Agrave; pr&eacute;voir` doit être reconnu.
    /// La crate `scraper` produit de l'inner_html avec ces entités non décodées.
    /// On injecte la fixture en HTML brut avec entités comme le ferait scraper.
    #[test]
    fn test_entites_html() {
        // On forge un HTML dont le inner_html() contiendra les entités telles quelles.
        // scraper::Html::parse_document décode les entités dans .text() mais
        // .inner_html() les re-sérialise en entités ASCII pour les caractères non-ASCII.
        // Ici on passe directement le HTML avec les entités dans le <strong>.
        let html = format!(
            r#"<html><body><div class="paragraph"><strong>&Agrave; pr&eacute;voir :</strong><ul><li>1 x combat seul.</li></ul></div></body></html>"#
        );
        let result = extract_a_prevoir(&html);
        assert_eq!(result.len(), 1, "La regex doit reconnaitre &Agrave; pr&eacute;voir");
        assert_eq!(result[0].combat_type, "solo");
        assert_eq!(result[0].count, "1");
    }
}

pub fn parse_guide_with_links(
    gid: &str,
    name: &str,
    csv: &str,
    links: &std::collections::HashMap<String, String>,
) -> GuideData {
    let mut reader = csv::ReaderBuilder::new()
        .has_headers(false)
        .flexible(true)
        .from_reader(csv.as_bytes());

    let rows: Vec<Vec<String>> = reader
        .records()
        .filter_map(|r| r.ok())
        .map(|r| r.iter().map(|s| s.to_string()).collect())
        .collect();

    let mut effect = String::new();
    let mut recommended_level: Option<u32> = None;
    let mut resources: Vec<Resource> = Vec::new();
    let mut combat_legend: Vec<CombatType> = Vec::new();
    let mut sections: Vec<Section> = Vec::new();

    // Phase 1: Extract effect (first non-empty cell in col 17+ within first 10 rows)
    for row in rows.iter().take(8) {
        for col in 14..row.len() {
            let cell = get_cell(row, col);
            if !cell.is_empty() && cell.len() > 20 && !cell.contains("Multiplicateur") {
                effect = cell.to_string();
                break;
            }
        }
        if !effect.is_empty() { break; }
    }

    // Phase 2: Scan entire sheet
    let mut in_quest_area = false;
    let mut current_section = Section { name: "Prérequis".to_string(), items: Vec::new() };
    let mut pending_group: Option<(Option<String>, Vec<QuestItem>)> = None;
    let mut legend_row_idx: Option<usize> = None;

    for (row_idx, row) in rows.iter().enumerate() {
        // Extract resources from right side
        if let Some((qty, rname)) = is_resource_row(row) {
            if !rname.is_empty() {
                resources.push(Resource { quantity: qty, name: rname });
            }
        }

        // Look for recommended level — check each cell individually to avoid cross-cell digit merging
        if recommended_level.is_none() {
            for cell in row.iter() {
                if let Some(lvl) = parse_level_from_cell(cell.trim()) {
                    recommended_level = Some(lvl);
                    break;
                }
            }
        }

        // Detect legend row (contains "Bashing" or "bash" or "Combat solo")
        let row_text_low: String = row.iter().map(|s| s.to_lowercase()).collect::<Vec<_>>().join("|");
        if row_text_low.contains("bash") || row_text_low.contains("combat solo") {
            legend_row_idx = Some(row_idx);
            combat_legend.clear();
            for (col, cell) in row.iter().enumerate() {
                let t = cell.trim();
                if !t.is_empty() && col < 36 {
                    combat_legend.push(CombatType { name: t.to_string(), column: col });
                }
            }
            // Check next row for secondary legend entries
            if let Some(next_row) = rows.get(row_idx + 1) {
                let has_checkbox = find_checkbox_col(next_row).is_some();
                if !has_checkbox {
                    for (col, cell) in next_row.iter().enumerate() {
                        let t = cell.trim();
                        if !t.is_empty() && col < 36 && !combat_legend.iter().any(|c| c.column == col) {
                            combat_legend.push(CombatType { name: t.to_string(), column: col });
                        }
                    }
                }
            }
        }

        // Detect section markers: "Prérequis", "Les quêtes"
        for col in 0..row.len().min(5) {
            let cell = get_cell(row, col);
            if cell == "Les quêtes" || cell == "Prérequis" || cell == "Les quetes" {
                // Flush pending group
                if let Some((note, quests)) = pending_group.take() {
                    if !quests.is_empty() {
                        current_section.items.push(SectionItem::Group(GroupItem { note, quests }));
                    }
                }
                if !current_section.items.is_empty() || !sections.is_empty() {
                    let prev = std::mem::replace(&mut current_section, Section {
                        name: cell.to_string(),
                        items: Vec::new(),
                    });
                    if !prev.items.is_empty() { sections.push(prev); }
                } else {
                    current_section.name = cell.to_string();
                }
                in_quest_area = true;
            }
        }

        // Zone header: non-empty text at col 14-18, no checkbox
        if in_quest_area && find_checkbox_col(row).is_none() {
            for col in 14..row.len().min(20) {
                let cell = get_cell(row, col);
                if !cell.is_empty() && cell.len() < 50
                    && !cell.contains("recommandé")
                    && !cell.contains("Rappel")
                    && !cell.contains("Lorsque")
                    && !cell.contains("quêtes sont dans")
                    && !cell.to_lowercase().contains("bash")
                    && !cell.to_lowercase().contains("combat")
                    && !cell.to_lowercase().contains("donjon")
                    && !cell.to_lowercase().contains("horaire")
                {
                    // Flush pending group
                    if let Some((note, quests)) = pending_group.take() {
                        if !quests.is_empty() {
                            current_section.items.push(SectionItem::Group(GroupItem { note, quests }));
                        }
                    }
                    current_section.items.push(SectionItem::Instruction(InstructionItem {
                        text: format!("__ZONE__:{}", cell),
                    }));
                    break;
                }
            }
        }

        // Instruction lines (non-quest text within quest area)
        if in_quest_area && find_checkbox_col(row).is_none() {
            // Check col 5-9 for group notes or instructions
            for col in 5..row.len().min(12) {
                let cell = get_cell(row, col);
                if !cell.is_empty() && cell.len() > 5 && col < 36 {
                    let lower = cell.to_lowercase();
                    // Group note pattern
                    if cell.contains("même zone") || cell.contains("ensemble") {
                        if let Some((_, ref mut quests)) = pending_group {
                            // Already in a group - flush and start new
                            let old = pending_group.take().unwrap();
                            if !old.1.is_empty() {
                                current_section.items.push(SectionItem::Group(GroupItem { note: old.0, quests: old.1 }));
                            }
                        }
                        pending_group = Some((Some(cell.to_string()), Vec::new()));
                        break;
                    }
                    // General instruction
                    if !lower.contains("recommandé")
                        && !lower.contains("rappel")
                        && !lower.contains("bash")
                        && !lower.contains("combat")
                        && !lower.contains("donjon")
                    {
                        current_section.items.push(SectionItem::Instruction(InstructionItem {
                            text: cell.to_string(),
                        }));
                    }
                    break;
                }
            }
        }

        // Quest rows
        if let Some((checkbox_col, completed)) = find_checkbox_col(row) {
            in_quest_area = true;
            let name_col = checkbox_col + 1;
            let quest_name = get_cell(row, name_col).to_string();
            if quest_name.is_empty() { continue; }

            let combat_indicators = parse_combat_indicators(row, &combat_legend, checkbox_col);
            let note = extract_note(row, name_col, &combat_legend);
            let url = Some(links.get(&quest_name).cloned()
                .unwrap_or_else(|| slugify(&quest_name)));

            let quest = QuestItem {
                name: quest_name,
                completed,
                combat_indicators,
                note,
                url,
            };

            // Col 7 = grouped (one extra indent vs col 6)
            if checkbox_col >= 7 {
                if pending_group.is_none() {
                    pending_group = Some((None, Vec::new()));
                }
                if let Some((_, ref mut quests)) = pending_group {
                    quests.push(quest);
                }
            } else {
                // Flush any pending group first
                if let Some((note, quests)) = pending_group.take() {
                    if !quests.is_empty() {
                        current_section.items.push(SectionItem::Group(GroupItem { note, quests }));
                    }
                }
                current_section.items.push(SectionItem::Quest(quest));
            }
        }
    }

    // Flush remaining
    if let Some((note, quests)) = pending_group.take() {
        if !quests.is_empty() {
            current_section.items.push(SectionItem::Group(GroupItem { note, quests }));
        }
    }
    if !current_section.items.is_empty() {
        sections.push(current_section);
    }

    GuideData {
        name: name.to_string(),
        gid: gid.to_string(),
        effect,
        recommended_level,
        combat_legend,
        resources,
        sections,
    }
}
