//! Q1 — mapping a Google comment back to the org structural element it anchors to.
//!
//! Pull (Q2) needs to file each reviewer comment under the right section of the
//! org file. Google gives us two hints, in descending order of reliability:
//!
//! 1. The comment **`anchor`** — an undocumented, brittle blob (D2; Google bug
//!    292610078). When it happens to carry a recoverable start index, we look up
//!    the position map ([`crate::project::PositionMap`]) for the element whose
//!    projected start index is the largest at or below it, and report that
//!    element's **containing section** (`:CUSTOM_ID:`).
//! 2. The **`quotedFileContent`** — the text the reviewer selected. When the
//!    anchor is unusable (empty, not JSON, or carrying no index we recognize), we
//!    fall back to locating the section whose projected text contains the quote
//!    (DI-5). This is the *primary* mechanism in practice: real kix anchors
//!    frequently carry no plain index, so the quote fallback is what usually
//!    resolves a comment.
//!
//! When neither hint resolves, the comment is reported as document-level. The
//! output is always a [`Location`] — a `:CUSTOM_ID:` or document-level, **never a
//! character offset** (DI-5).
//!
//! This module is **pure and total** (EI-4): the anchor parse is best-effort and
//! never panics on a malformed blob (EI-2); it degrades to the fallback instead.

use kb::ast::{Block, Document, ListItem, TableCell};
use serde_json::Value;

use crate::google::drive::Anchor;
use crate::project::{DOCUMENT_PARENT, PositionMap, heading_id, inline_text};

/// Where a reviewer comment anchors in the org file.
///
/// Per DI-5 this is a structural location, never a character offset: either the
/// `:CUSTOM_ID:` of the containing heading, or document-level when nothing
/// resolves.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Location {
    /// The comment resolves to the heading bearing this `:CUSTOM_ID:`.
    Section(String),
    /// The comment could not be tied to a section; it anchors at document level.
    Document,
}

/// Resolves Google comments to org [`Location`]s for one document.
///
/// Built once per pull from the projected [`PositionMap`] and the parsed org
/// [`Document`]; [`resolve`](Self::resolve) is then called per comment. The
/// section texts (for the quoted-text fallback) are flattened and
/// whitespace-normalized once at construction.
pub struct Resolver<'a> {
    positions: &'a PositionMap,
    /// `(section custom-id, normalized projected text)` in document order. First
    /// match wins, so an earlier section is preferred when a quote is ambiguous.
    sections: Vec<(String, String)>,
}

impl<'a> Resolver<'a> {
    /// Build a resolver from the projected position map and the parsed org body.
    #[must_use]
    pub fn new(positions: &'a PositionMap, document: &Document) -> Self {
        let mut sections: Vec<(String, String)> = Vec::new();
        collect_sections(&document.blocks, DOCUMENT_PARENT, &mut sections);
        Self {
            positions,
            sections,
        }
    }

    /// Resolve one comment's `anchor`/`quotedFileContent` to a [`Location`].
    ///
    /// Tries the anchor's recoverable start index first, then the quoted-text
    /// fallback, then document-level — exactly the precedence DI-5 prescribes.
    #[must_use]
    pub fn resolve(&self, anchor: Option<&Anchor>, quoted_text: Option<&str>) -> Location {
        if let Some(start) = anchor.and_then(anchor_start)
            && let Some(section) = self.section_for_index(start)
        {
            return Location::Section(section);
        }
        if let Some(quote) = quoted_text
            && let Some(section) = self.section_for_quote(quote)
        {
            return Location::Section(section);
        }
        Location::Document
    }

    /// The containing section of the position with the largest index at or below
    /// `start`, or `None` when nothing precedes it or it is document-level.
    fn section_for_index(&self, start: u32) -> Option<String> {
        self.positions
            .iter()
            .filter(|(_, pos)| pos.index <= start)
            .max_by_key(|(_, pos)| pos.index)
            .map(|(id, _)| containing_section(id))
            .filter(|section| *section != DOCUMENT_PARENT)
            .map(str::to_owned)
    }

    /// The first section whose projected text contains `quote`, comparing on
    /// whitespace-normalized text so wrapping differences do not defeat the match.
    fn section_for_quote(&self, quote: &str) -> Option<String> {
        let needle = normalize_ws(quote);
        if needle.is_empty() {
            return None;
        }
        self.sections
            .iter()
            .find(|(_, text)| text.contains(&needle))
            .map(|(id, _)| id.clone())
    }
}

/// The containing-section id of a position key: the part before the first `/`.
///
/// Heading keys are bare `:CUSTOM_ID:`s (no slash); non-heading keys are
/// `<section>/<type>-<n>`. Either way the prefix is the heading the element lives
/// under, which is the only id with a real drawer in the body for elisp to find.
fn containing_section(id: &str) -> &str {
    match id.split_once('/') {
        Some((section, _)) => section,
        None => id,
    }
}

// ── Section text index (quoted-text fallback) ────────────────────────────────

/// Walk the blocks under `parent`, accumulating each heading's projected text
/// into `out` keyed by the heading's id. Pre-heading content (under
/// [`DOCUMENT_PARENT`]) is dropped: a quote can never resolve to document level.
fn collect_sections(blocks: &[Block], parent: &str, out: &mut Vec<(String, String)>) {
    for block in blocks {
        if let Block::Heading {
            title, children, ..
        } = block
        {
            let id = heading_id(title, children);
            append_text(out, &id, title.as_str());
            collect_sections(children, &id, out);
        } else if parent != DOCUMENT_PARENT {
            let text = block_text(block);
            if !text.is_empty() {
                append_text(out, parent, &text);
            }
        }
    }
}

/// The visible projected text of a non-heading block (for quote matching).
fn block_text(block: &Block) -> String {
    match block {
        Block::Paragraph { inlines } => normalize_ws(&inline_text(inlines)),
        Block::SrcBlock { content, .. } | Block::ExampleBlock { content } => normalize_ws(content),
        Block::QuoteBlock { children } => join_block_text(children),
        Block::List { items, .. } => join_item_text(items),
        Block::Table { rows } => join_cell_text(rows),
        Block::Heading { .. }
        | Block::HorizontalRule
        | Block::PropertyDrawer { .. }
        | Block::LogbookDrawer { .. }
        | Block::Planning { .. }
        | Block::Comment { .. }
        | Block::Keyword { .. }
        | Block::BlankLine => String::new(),
    }
}

fn join_block_text(blocks: &[Block]) -> String {
    join_nonempty(blocks.iter().map(block_text))
}

fn join_item_text(items: &[ListItem]) -> String {
    join_nonempty(items.iter().map(|item| join_block_text(&item.content)))
}

fn join_cell_text(rows: &[Vec<TableCell>]) -> String {
    join_nonempty(
        rows.iter()
            .flatten()
            .map(|cell| normalize_ws(&inline_text(&cell.inlines))),
    )
}

/// Space-join the non-empty members of `parts`.
fn join_nonempty<I: Iterator<Item = String>>(parts: I) -> String {
    let kept: Vec<String> = parts.filter(|part| !part.is_empty()).collect();
    kept.join(" ")
}

/// Append `text` to the section entry for `id`, space-separated, creating the
/// entry on first sight so document order of headings is preserved.
fn append_text(out: &mut Vec<(String, String)>, id: &str, text: &str) {
    let normalized = normalize_ws(text);
    if normalized.is_empty() {
        return;
    }
    if let Some(entry) = out.iter_mut().find(|(key, _)| key == id) {
        entry.1.push(' ');
        entry.1.push_str(&normalized);
    } else {
        out.push((id.to_owned(), normalized));
    }
}

/// Collapse all runs of whitespace to a single space and trim the ends, so a
/// quote that Google rewrapped still matches the projected prose.
fn normalize_ws(text: &str) -> String {
    text.split_whitespace().collect::<Vec<_>>().join(" ")
}

// ── Anchor index recovery (best-effort, never panics) ────────────────────────

/// Best-effort extraction of a start index from a comment anchor blob.
///
/// The anchor is opaque JSON (D2); we parse it leniently and search for a map
/// carrying both a recognizable start and end key. A blob that is empty, not
/// JSON, or carries no such pair yields `None` so the caller falls back to the
/// quoted text. Never panics (EI-2).
fn anchor_start(anchor: &Anchor) -> Option<u32> {
    let trimmed = anchor.0.trim();
    if trimmed.is_empty() {
        return None;
    }
    let value: Value = serde_json::from_str(trimmed).ok()?;
    u32::try_from(find_start(&value)?).ok()
}

/// Recursively find the start index of the first JSON object that carries both a
/// start key (`startIndex`/`start`/`s`) and an end key (`endIndex`/`end`/`e`).
///
/// Requiring both keys avoids matching an unrelated short key elsewhere in the
/// blob (ported from the reference implementation's anchor heuristic).
fn find_start(value: &Value) -> Option<i64> {
    match value {
        Value::Object(map) => {
            let start = int_for_keys(map, &["startIndex", "start", "s"]);
            let end = int_for_keys(map, &["endIndex", "end", "e"]);
            if let (Some(start), Some(_)) = (start, end) {
                return Some(start);
            }
            map.values().find_map(find_start)
        }
        Value::Array(items) => items.iter().find_map(find_start),
        _ => None,
    }
}

/// The integer value of the first present key in `keys`, if any is an integer.
fn int_for_keys(map: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<i64> {
    keys.iter()
        .find_map(|key| map.get(*key).and_then(Value::as_i64))
}

#[cfg(test)]
mod tests {
    use super::{Location, Resolver};
    use crate::google::drive::Anchor;
    use crate::project::{ElementKind, Position, PositionMap};
    use kb::ast::{Block, Document, Inline, Title};

    fn heading(title: &str, custom_id: &str, children: Vec<Block>) -> Block {
        let mut all = vec![Block::PropertyDrawer {
            entries: vec![("CUSTOM_ID".to_owned(), format!(" {custom_id}"))],
        }];
        all.extend(children);
        Block::Heading {
            level: 1,
            title: Title(title.to_owned()),
            tags: vec![],
            children: all,
        }
    }

    fn paragraph(text: &str) -> Block {
        Block::Paragraph {
            inlines: vec![Inline::Plain(text.to_owned())],
        }
    }

    /// A two-section doc: `sec-intro` (heading + one paragraph) then `sec-body`.
    fn sample_doc() -> Document {
        Document {
            blocks: vec![
                heading(
                    "Introduction",
                    "sec-intro",
                    vec![paragraph("The quick brown fox jumps over the lazy dog.")],
                ),
                heading(
                    "Body",
                    "sec-body",
                    vec![paragraph("A wholly unrelated second paragraph.")],
                ),
            ],
        }
    }

    /// Positions roughly matching `sample_doc`'s projection: the intro heading at
    /// 1, its paragraph at 14, the body heading at 60, its paragraph at 66.
    fn sample_positions() -> PositionMap {
        let mut map = PositionMap::new();
        let at = |index, kind| Position { index, kind };
        map.insert("sec-intro".to_owned(), at(1, ElementKind::Heading));
        map.insert(
            "sec-intro/paragraph-1".to_owned(),
            at(14, ElementKind::Paragraph),
        );
        map.insert("sec-body".to_owned(), at(60, ElementKind::Heading));
        map.insert(
            "sec-body/paragraph-1".to_owned(),
            at(66, ElementKind::Paragraph),
        );
        map
    }

    #[test]
    fn anchor_index_maps_to_containing_section() {
        let positions = sample_positions();
        let document = sample_doc();
        let resolver = Resolver::new(&positions, &document);

        // start=20 → largest position ≤ 20 is sec-intro/paragraph-1 @14 → sec-intro.
        let anchor = Anchor(r#"{"startIndex":20,"endIndex":25}"#.to_owned());
        assert_eq!(
            resolver.resolve(Some(&anchor), None),
            Location::Section("sec-intro".to_owned())
        );
    }

    #[test]
    fn anchor_index_in_later_section_resolves_there() {
        let positions = sample_positions();
        let document = sample_doc();
        let resolver = Resolver::new(&positions, &document);

        let anchor = Anchor(r#"{"a":[{"docs":{"startIndex":70,"endIndex":75}}]}"#.to_owned());
        assert_eq!(
            resolver.resolve(Some(&anchor), None),
            Location::Section("sec-body".to_owned())
        );
    }

    #[test]
    fn garbage_anchor_falls_back_to_quoted_text() {
        // DI-5: a deliberately-unusable anchor must recover via the quote.
        let positions = sample_positions();
        let document = sample_doc();
        let resolver = Resolver::new(&positions, &document);

        let anchor = Anchor("kix.totally-opaque-undecodable-blob".to_owned());
        let location = resolver.resolve(Some(&anchor), Some("quick brown fox"));
        assert_eq!(location, Location::Section("sec-intro".to_owned()));
    }

    #[test]
    fn valid_json_anchor_without_indices_falls_back() {
        let positions = sample_positions();
        let document = sample_doc();
        let resolver = Resolver::new(&positions, &document);

        // Parses as JSON, but carries no start/end pair we recognize.
        let anchor = Anchor(r#"{"type":"workspace.comment.anchor.v1","client":"docs"}"#.to_owned());
        let location = resolver.resolve(Some(&anchor), Some("unrelated second paragraph"));
        assert_eq!(location, Location::Section("sec-body".to_owned()));
    }

    #[test]
    fn quote_matches_across_rewrapped_whitespace() {
        let positions = sample_positions();
        let document = sample_doc();
        let resolver = Resolver::new(&positions, &document);

        // Google may hand back the quote rewrapped with newlines/extra spaces.
        let location = resolver.resolve(None, Some("quick   brown\n  fox jumps"));
        assert_eq!(location, Location::Section("sec-intro".to_owned()));
    }

    #[test]
    fn no_anchor_and_no_matching_quote_is_document_level() {
        let positions = sample_positions();
        let document = sample_doc();
        let resolver = Resolver::new(&positions, &document);

        assert_eq!(
            resolver.resolve(None, Some("text that appears in no section")),
            Location::Document
        );
        assert_eq!(resolver.resolve(None, None), Location::Document);
    }

    #[test]
    fn empty_anchor_is_treated_as_absent() {
        let positions = sample_positions();
        let document = sample_doc();
        let resolver = Resolver::new(&positions, &document);

        let anchor = Anchor("   ".to_owned());
        assert_eq!(
            resolver.resolve(Some(&anchor), Some("lazy dog")),
            Location::Section("sec-intro".to_owned())
        );
    }

    #[test]
    fn index_below_first_position_falls_back_to_quote() {
        let mut positions = PositionMap::new();
        positions.insert(
            "sec-intro".to_owned(),
            Position {
                index: 100,
                kind: ElementKind::Heading,
            },
        );
        let document = sample_doc();
        let resolver = Resolver::new(&positions, &document);

        // start=5 precedes every position → no index hit → quote fallback.
        let anchor = Anchor(r#"{"startIndex":5,"endIndex":9}"#.to_owned());
        assert_eq!(
            resolver.resolve(Some(&anchor), Some("lazy dog")),
            Location::Section("sec-intro".to_owned())
        );
    }

    #[test]
    fn negative_start_index_is_rejected() {
        let positions = sample_positions();
        let document = sample_doc();
        let resolver = Resolver::new(&positions, &document);

        let anchor = Anchor(r#"{"startIndex":-3,"endIndex":-1}"#.to_owned());
        // Unusable index → fall through to document level (no quote given).
        assert_eq!(resolver.resolve(Some(&anchor), None), Location::Document);
    }
}
