//! P6 — the `** Active Comments` subtree: human-facing `TODO`/`DONE` headings,
//! one per pulled Google comment, keyed by `:COMMENT_ID:`.
//!
//! Under `* GDOC_METADATA`, this section sits after `** Sync State`. Each comment
//! is a level-3 heading carrying its id, author, date, and anchored section in a
//! property drawer, plus the quoted document text and any replies in the body.
//!
//! Three operations back the sync flows:
//!
//! - **parse** ([`parse_entries`]) extracts `(id, state, section)` from existing
//!   headings — push reads `DONE` to resolve in Google; pull reads the known ids
//!   to avoid re-inserting.
//! - **render/merge** ([`render_section`]) appends *new* comments as `TODO`
//!   headings. **Merge-by-id (A3):** existing headings are preserved byte-for-byte
//!   and never reordered, so operator notes and clocking survive.
//! - **clean** ([`clean_section`]) drops the `DONE` subtrees and keeps the rest
//!   verbatim.
//!
//! Line scanning is **org-block-aware**: lines inside a `#+begin_…`/`#+end_…`
//! block (e.g. a quoted snippet that happens to start with `***`) are never
//! mistaken for headings. Parsing is total (EI-4) — non-conforming headings are
//! skipped, never panicked on.

use crate::google::drive::{Author, Comment};

/// The level-2 heading that opens the comment section.
const SECTION_HEADING: &str = "** Active Comments";

/// The TODO/DONE state of a comment heading.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommentState {
    /// An open comment (rendered by pull; the operator has not addressed it).
    Todo,
    /// A comment the operator marked `DONE`; push resolves it in Google.
    Done,
}

/// An operator-authored reply (`**** REPLY` subheading) under a comment that has
/// not yet been posted to Google.
///
/// Whether a reply has been posted is tracked in the sync state (by `comment_id` +
/// `content`), not by mutating this subtree — so the operator's text is never
/// rewritten (A3) and the bookkeeping stays in machine state (DI-3).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingReply {
    /// The `:COMMENT_ID:` of the comment this reply answers.
    pub comment_id: String,
    /// The reply text (the subheading's body, whitespace-trimmed).
    pub content: String,
}

/// The machine-relevant fields parsed from one comment heading.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentEntry {
    /// The Drive comment id (`:COMMENT_ID:`).
    pub id: String,
    /// Whether the heading is `TODO` or `DONE`.
    pub state: CommentState,
    /// The anchored section's `CUSTOM_ID` (`:COMMENT_SECTION:`), or `None` for a
    /// document-level comment.
    pub section: Option<String>,
}

/// Parse every comment heading in the `** Active Comments` section of `region`.
///
/// Headings without a `:COMMENT_ID:` are skipped (operator-authored content is
/// not treated as a comment). Total and panic-free.
#[must_use]
pub fn parse_entries(region: &str) -> Vec<CommentEntry> {
    let Some(body) = section_body(region) else {
        return Vec::new();
    };
    let (_, blocks) = split_blocks(body);
    blocks
        .iter()
        .filter_map(|block| parse_block(block))
        .collect()
}

/// Render the full `** Active Comments` subtree: the heading, the existing body
/// preserved verbatim (A3), then each `new` comment whose id is not already
/// present appended as a `TODO` heading.
///
/// Each new entry pairs a [`Comment`] with the `CUSTOM_ID` it anchors to (or
/// `None` for document-level).
#[must_use]
pub fn render_section(region: &str, new: &[(&Comment, Option<&str>)]) -> String {
    let known = known_ids(region);
    let mut out = String::from(SECTION_HEADING);
    out.push('\n');
    if let Some(body) = section_body(region) {
        out.push_str(body);
    }
    for (comment, section) in new {
        if known.iter().any(|id| id == &comment.id) {
            continue;
        }
        ensure_trailing_blank(&mut out);
        out.push_str(&render_comment(comment, *section));
    }
    out
}

/// Render the `** Active Comments` subtree with every `DONE` comment subtree
/// removed; non-`DONE` blocks and any preamble are kept verbatim.
#[must_use]
pub fn clean_section(region: &str) -> String {
    let mut out = String::from(SECTION_HEADING);
    out.push('\n');
    if let Some(body) = section_body(region) {
        let (preamble, blocks) = split_blocks(body);
        out.push_str(preamble);
        for block in blocks {
            if block_state(block) != CommentState::Done {
                out.push_str(block);
            }
        }
    }
    out
}

/// Render a single new comment as a `TODO` heading (used by pull-merge).
#[must_use]
pub fn render_comment(comment: &Comment, section: Option<&str>) -> String {
    let mut out = String::new();
    out.push_str("*** TODO ");
    out.push_str(&author_label(&comment.author));
    out.push_str(": ");
    out.push_str(&one_line(&comment.content));
    out.push('\n');

    out.push_str(":PROPERTIES:\n");
    push_property(&mut out, "COMMENT_ID", &comment.id);
    if let Some(name) = &comment.author.display_name {
        push_property(&mut out, "COMMENT_AUTHOR", name);
    }
    if let Some(email) = &comment.author.email {
        push_property(&mut out, "COMMENT_EMAIL", email);
    }
    if let Some(date) = &comment.created_time {
        push_property(&mut out, "COMMENT_DATE", date);
    }
    if let Some(section) = section {
        push_property(&mut out, "COMMENT_SECTION", section);
    }
    out.push_str(":END:\n");

    if let Some(quote) = comment.quoted_text.as_deref() {
        if !quote.trim().is_empty() {
            out.push_str("#+begin_quote\n");
            out.push_str(quote);
            if !quote.ends_with('\n') {
                out.push('\n');
            }
            out.push_str("#+end_quote\n");
        }
    }
    for reply in &comment.replies {
        out.push_str(&author_label(&reply.author));
        out.push_str(": ");
        out.push_str(&one_line(&reply.content));
        out.push('\n');
    }
    out
}

/// Extract every operator-authored `**** REPLY` subheading from the comments in
/// `region`, paired with the id of the comment it answers.
///
/// This reads the operator's authored replies; deciding which are already posted
/// (and thus must not be re-posted) is the caller's job, against the sync state.
/// Total and panic-free.
#[must_use]
pub fn pending_replies(region: &str) -> Vec<PendingReply> {
    let Some(body) = section_body(region) else {
        return Vec::new();
    };
    let (_, blocks) = split_blocks(body);
    let mut out = Vec::new();
    for block in &blocks {
        let Some(comment_id) = drawer_value(block, "COMMENT_ID") else {
            continue;
        };
        for content in block_reply_contents(block) {
            out.push(PendingReply {
                comment_id: comment_id.clone(),
                content,
            });
        }
    }
    out
}

/// The trimmed body text of each `**** REPLY` subheading within one comment block.
///
/// Block-aware: a `#+begin_…` region inside the comment (e.g. the quoted text) is
/// skipped so a stray heading-like line in a quote is not mistaken for a reply.
fn block_reply_contents(block: &str) -> Vec<String> {
    let mut replies: Vec<Vec<&str>> = Vec::new();
    let mut current: Option<Vec<&str>> = None;
    let mut depth: i32 = 0;
    for line in block.lines() {
        let stripped = line.trim_end_matches(['\n', '\r']);
        if depth == 0 && is_reply_heading(stripped) {
            replies.extend(current.take());
            current = Some(Vec::new());
        } else if depth == 0 && heading_level(stripped) > 0 {
            // Any other heading ends the reply currently being collected.
            replies.extend(current.take());
        } else if let Some(lines) = current.as_mut() {
            lines.push(stripped);
        }
        adjust_depth(stripped, &mut depth);
    }
    replies.extend(current.take());
    replies
        .iter()
        .filter_map(|lines| join_reply_lines(lines))
        .collect()
}

/// Join a reply subheading's body lines into trimmed text, dropping any property
/// drawer; `None` when the reply has no text.
fn join_reply_lines(lines: &[&str]) -> Option<String> {
    let text = lines
        .iter()
        .filter(|line| !is_drawer_line(line))
        .copied()
        .collect::<Vec<_>>()
        .join("\n");
    let trimmed = text.trim();
    (!trimmed.is_empty()).then(|| trimmed.to_owned())
}

/// Whether a line is part of a property drawer (`:PROPERTIES:`, `:END:`, or a
/// `:KEY:`/`:KEY: value` entry) — excluded from reply text.
fn is_drawer_line(line: &str) -> bool {
    line.trim()
        .strip_prefix(':')
        .is_some_and(|rest| rest.contains(':'))
}

/// Whether `line` is a level-4 `REPLY` subheading (the operator's reply marker).
fn is_reply_heading(line: &str) -> bool {
    heading_level(line) == 4
        && line
            .get(4..)
            .map(str::trim_start)
            .and_then(|rest| rest.split_whitespace().next())
            .is_some_and(|word| word.eq_ignore_ascii_case("REPLY"))
}

/// The heading level of `line` (count of leading `*` when followed by a space), or
/// 0 when it is not a heading.
fn heading_level(line: &str) -> usize {
    let level = star_level(line);
    if level > 0 && line.get(level..).is_some_and(|rest| rest.starts_with(' ')) {
        level
    } else {
        0
    }
}

// ── Parsing helpers ─────────────────────────────────────────────────────────

/// The ids of all parseable comment headings in `region`.
fn known_ids(region: &str) -> Vec<String> {
    parse_entries(region)
        .into_iter()
        .map(|entry| entry.id)
        .collect()
}

/// Parse one comment block into a [`CommentEntry`], or `None` if it lacks an id.
fn parse_block(block: &str) -> Option<CommentEntry> {
    let id = drawer_value(block, "COMMENT_ID")?;
    let state = block_state(block);
    let section = drawer_value(block, "COMMENT_SECTION");
    Some(CommentEntry { id, state, section })
}

/// The TODO/DONE state of a comment block, read from its heading keyword.
fn block_state(block: &str) -> CommentState {
    let heading = block.lines().next().unwrap_or_default();
    match todo_keyword(heading) {
        Some("DONE") => CommentState::Done,
        _ => CommentState::Todo,
    }
}

/// The TODO keyword of a heading (the first all-uppercase token after the stars),
/// or `None` when the heading has no keyword.
fn todo_keyword(heading: &str) -> Option<&str> {
    let after_stars = heading.trim_start_matches('*').strip_prefix(' ')?;
    let token = after_stars.split_whitespace().next()?;
    let is_keyword = !token.is_empty() && token.chars().all(|ch| ch.is_ascii_uppercase());
    is_keyword.then_some(token)
}

/// The value of a `:KEY: value` property line within `block` (case-insensitive
/// key), if present.
fn drawer_value(block: &str, key: &str) -> Option<String> {
    block.lines().find_map(|line| {
        let rest = line.trim().strip_prefix(':')?;
        let (found, value) = rest.split_once(':')?;
        found
            .eq_ignore_ascii_case(key)
            .then(|| value.trim().to_owned())
    })
}

// ── Section + block boundaries (org-block-aware) ────────────────────────────

/// The verbatim body of the `** Active Comments` section (everything between its
/// heading line and the next level-≤2 heading or end of input), if present.
fn section_body(region: &str) -> Option<&str> {
    let (start, end) = section_bounds(region)?;
    region.get(start..end)
}

/// Byte bounds `(body_start, body_end)` of the `** Active Comments` section body.
fn section_bounds(region: &str) -> Option<(usize, usize)> {
    let mut offset = 0;
    let mut body_start: Option<usize> = None;
    let mut depth: i32 = 0;
    for line in region.split_inclusive('\n') {
        let stripped = line.trim_end_matches(['\n', '\r']);
        match body_start {
            None => {
                if depth == 0 && stripped.trim() == SECTION_HEADING {
                    body_start = Some(offset + line.len());
                }
            }
            Some(start) => {
                if depth == 0 && is_heading_at_most_2(stripped) {
                    return Some((start, offset));
                }
            }
        }
        adjust_depth(stripped, &mut depth);
        offset += line.len();
    }
    body_start.map(|start| (start, region.len()))
}

/// Split a section body into `(preamble, blocks)`, where each block is the
/// verbatim text of one `*** ` comment subtree. Block starts inside `#+begin_…`
/// blocks are ignored (a quoted `***` line is not a heading).
fn split_blocks(body: &str) -> (&str, Vec<&str>) {
    let mut starts = Vec::new();
    let mut offset = 0;
    let mut depth: i32 = 0;
    for line in body.split_inclusive('\n') {
        let stripped = line.trim_end_matches(['\n', '\r']);
        if depth == 0 && is_comment_heading(stripped) {
            starts.push(offset);
        }
        adjust_depth(stripped, &mut depth);
        offset += line.len();
    }

    let first = starts.first().copied().unwrap_or(body.len());
    let preamble = body.get(..first).unwrap_or("");
    let mut blocks = Vec::with_capacity(starts.len());
    for (index, &start) in starts.iter().enumerate() {
        let end = starts.get(index + 1).copied().unwrap_or(body.len());
        if let Some(block) = body.get(start..end) {
            blocks.push(block);
        }
    }
    (preamble, blocks)
}

/// Update the org-block nesting `depth` for a line.
fn adjust_depth(line: &str, depth: &mut i32) {
    if opens_block(line) {
        *depth += 1;
    } else if closes_block(line) {
        *depth = depth.saturating_sub(1);
    }
}

fn opens_block(line: &str) -> bool {
    let trimmed = line.trim_start();
    trimmed
        .get(..8)
        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("#+begin_"))
}

fn closes_block(line: &str) -> bool {
    let trimmed = line.trim_start();
    trimmed
        .get(..6)
        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("#+end_"))
}

/// Number of leading `*` characters on a line.
fn star_level(line: &str) -> usize {
    line.chars().take_while(|&ch| ch == '*').count()
}

/// Whether `line` is a heading of level 1 or 2 (a section boundary).
fn is_heading_at_most_2(line: &str) -> bool {
    let level = star_level(line);
    (level == 1 || level == 2) && line.get(level..).is_some_and(|rest| rest.starts_with(' '))
}

/// Whether `line` is a level-3 heading (a comment-block start).
fn is_comment_heading(line: &str) -> bool {
    star_level(line) == 3 && line.get(3..).is_some_and(|rest| rest.starts_with(' '))
}

// ── Rendering helpers ───────────────────────────────────────────────────────

/// A display label for an author: name, else email, else `Unknown`.
fn author_label(author: &Author) -> String {
    author
        .display_name
        .clone()
        .or_else(|| author.email.clone())
        .unwrap_or_else(|| "Unknown".to_owned())
}

/// Collapse all runs of whitespace (including newlines) to single spaces so the
/// text is safe to place on a single heading or reply line.
fn one_line(text: &str) -> String {
    text.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// Append a `:KEY: value` property line.
fn push_property(out: &mut String, key: &str, value: &str) {
    out.push(':');
    out.push_str(key);
    out.push_str(": ");
    out.push_str(value);
    out.push('\n');
}

/// Ensure `out` ends with a blank line, so an appended block is visually separated.
fn ensure_trailing_blank(out: &mut String) {
    if !out.ends_with('\n') {
        out.push('\n');
    }
    if !out.ends_with("\n\n") {
        out.push('\n');
    }
}

#[cfg(test)]
mod tests {
    use super::{
        CommentEntry, CommentState, PendingReply, clean_section, parse_entries, pending_replies,
        render_comment, render_section,
    };
    use crate::google::drive::{Author, Comment, Reply};

    fn comment(id: &str, content: &str) -> Comment {
        Comment {
            id: id.to_owned(),
            author: Author {
                display_name: Some("Alice".to_owned()),
                email: Some("alice@example.com".to_owned()),
            },
            content: content.to_owned(),
            created_time: Some("2026-06-01T12:00:00+00:00".to_owned()),
            resolved: false,
            anchor: None,
            quoted_text: Some("the projected sentence".to_owned()),
            replies: vec![Reply {
                author: Author {
                    display_name: Some("Bob".to_owned()),
                    email: None,
                },
                content: "Agreed".to_owned(),
                created_time: None,
            }],
        }
    }

    /// A machine region with a `** Sync State` block ahead of the comments, to
    /// exercise block-aware section detection.
    fn region_with(active: &str) -> String {
        format!(
            "* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n{active}"
        )
    }

    #[test]
    fn renders_a_todo_heading_with_drawer_quote_and_reply() {
        let rendered = render_comment(&comment("C1", "Please clarify."), Some("sec-intro"));
        assert!(rendered.starts_with("*** TODO Alice: Please clarify.\n"));
        assert!(rendered.contains(":COMMENT_ID: C1\n"));
        assert!(rendered.contains(":COMMENT_SECTION: sec-intro\n"));
        assert!(rendered.contains(":COMMENT_EMAIL: alice@example.com\n"));
        assert!(rendered.contains("#+begin_quote\nthe projected sentence\n#+end_quote\n"));
        assert!(rendered.contains("Bob: Agreed\n"));
    }

    #[test]
    fn parses_mixed_todo_and_done_with_sections() {
        let active = "** Active Comments\n\
            *** TODO Alice: open\n:PROPERTIES:\n:COMMENT_ID: C1\n:COMMENT_SECTION: sec-intro\n:END:\n\n\
            *** DONE Bob: handled\n:PROPERTIES:\n:COMMENT_ID: C2\n:END:\n";
        let entries = parse_entries(&region_with(active));
        assert_eq!(
            entries,
            vec![
                CommentEntry {
                    id: "C1".to_owned(),
                    state: CommentState::Todo,
                    section: Some("sec-intro".to_owned()),
                },
                CommentEntry {
                    id: "C2".to_owned(),
                    state: CommentState::Done,
                    section: None,
                },
            ]
        );
    }

    #[test]
    fn parse_skips_headings_without_a_comment_id() {
        let active = "** Active Comments\n*** TODO operator's own note\nsome text\n";
        assert!(parse_entries(&region_with(active)).is_empty());
    }

    #[test]
    fn quoted_heading_like_line_does_not_break_parsing() {
        // The quote body contains a line starting with `***`; block-awareness must
        // keep it inside the single comment block.
        let active = "** Active Comments\n\
            *** TODO Alice: see below\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n\
            #+begin_quote\n*** not a heading\n#+end_quote\n";
        let entries = parse_entries(&region_with(active));
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].id, "C1");
    }

    #[test]
    fn merge_appends_new_and_preserves_existing_verbatim() {
        // Existing block carries an operator annotation that must survive (A3).
        let active = "** Active Comments\n\
            *** TODO Alice: existing\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n: operator note — clocking\n";
        let region = region_with(active);

        let new_comment = comment("C2", "fresh");
        let merged = render_section(&region, &[(&new_comment, Some("sec-two"))]);

        // Existing block (incl. the operator note) preserved byte-for-byte.
        assert!(merged.contains(":COMMENT_ID: C1\n:END:\n: operator note — clocking\n"));
        // New comment appended.
        assert!(merged.contains(":COMMENT_ID: C2\n"));
        assert!(merged.contains(":COMMENT_SECTION: sec-two\n"));
    }

    #[test]
    fn merge_is_idempotent_for_known_ids() {
        let active =
            "** Active Comments\n*** TODO Alice: existing\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n";
        let region = region_with(active);
        let existing = comment("C1", "existing");
        let merged = render_section(&region, &[(&existing, Some("sec-intro"))]);
        // C1 appears exactly once — not re-inserted.
        assert_eq!(merged.matches(":COMMENT_ID: C1\n").count(), 1);
    }

    #[test]
    fn merge_creates_section_when_absent() {
        let region = "* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n";
        let new_comment = comment("C1", "first");
        let merged = render_section(region, &[(&new_comment, None)]);
        assert!(merged.starts_with("** Active Comments\n"));
        assert!(merged.contains(":COMMENT_ID: C1\n"));
    }

    #[test]
    fn clean_removes_only_done_subtrees() {
        let active = "** Active Comments\n\
            *** TODO Alice: keep\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n\n\
            *** DONE Bob: drop\n:PROPERTIES:\n:COMMENT_ID: C2\n:END:\n";
        let cleaned = clean_section(&region_with(active));
        assert!(cleaned.contains(":COMMENT_ID: C1\n"));
        assert!(!cleaned.contains(":COMMENT_ID: C2\n"));
        assert!(!cleaned.contains("DONE"));
    }

    #[test]
    fn no_section_yields_empty_parse() {
        let region = "* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions) (collaborators))\n#+end_src\n";
        assert!(parse_entries(region).is_empty());
    }

    #[test]
    fn pending_replies_extracts_operator_authored_replies() {
        let active = "** Active Comments\n\
            *** TODO Alice: please clarify\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n\
            #+begin_quote\nthe quoted text\n#+end_quote\n\
            **** REPLY\nClarified in the next paragraph.\n\n\
            *** TODO Bob: typo\n:PROPERTIES:\n:COMMENT_ID: C2\n:END:\n\
            **** REPLY\nFixed,\nthanks.\n";
        let replies = pending_replies(&region_with(active));
        assert_eq!(
            replies,
            vec![
                PendingReply {
                    comment_id: "C1".to_owned(),
                    content: "Clarified in the next paragraph.".to_owned(),
                },
                PendingReply {
                    comment_id: "C2".to_owned(),
                    content: "Fixed,\nthanks.".to_owned(),
                },
            ]
        );
    }

    #[test]
    fn pending_replies_ignores_comments_without_a_reply_and_quoted_text() {
        // A comment with only a quote (no REPLY subheading) yields nothing, and a
        // `****`-like line inside the quote is not mistaken for a reply.
        let active = "** Active Comments\n\
            *** TODO Alice: see below\n:PROPERTIES:\n:COMMENT_ID: C1\n:END:\n\
            #+begin_quote\n**** not a reply, just quoted\n#+end_quote\n";
        assert!(pending_replies(&region_with(active)).is_empty());
    }
}
