//! P5 — the tool-owned `** Sync State` block.
//!
//! Under `* GDOC_METADATA`, `** Sync State` holds a single s-expression (A5 — not
//! JSON) in a `#+begin_src emacs-lisp` block. It is **pure machine state**, fully
//! regenerated on every sync: the projection position map ([`crate::project`]) plus
//! a cache of the reviewers (collaborators) seen on pulled comments. The operator
//! never hand-edits it.
//!
//! ## Block shape
//!
//! ```text
//! ** Sync State
//! #+begin_src emacs-lisp
//! (gdoc-sync-state 1
//!  (positions (pos "sec-intro" 1 heading) (pos "sec-intro/paragraph-1" 5 paragraph))
//!  (collaborators (collab "alice@example.com" "Alice Smith")))
//! #+end_src
//! ```
//!
//! Parsing is total (EI-4): a *missing* block decodes to an empty-but-valid state,
//! while a *present-but-corrupt* block surfaces a typed [`Error::Sexp`] — never a
//! panic.

use crate::error::{Error, Result};
use crate::project::{ElementKind, Position, PositionMap};
use crate::sexp::{Sexp, SexpError, parse};

/// Sync-state s-expression schema version. Bump when the shape changes.
const VERSION: i64 = 1;

/// The `** Sync State` heading line.
const HEADING: &str = "** Sync State";
/// The source-block fence opening the machine s-expression.
const SRC_OPEN: &str = "#+begin_src emacs-lisp";
/// The source-block fence closing the machine s-expression.
const SRC_CLOSE: &str = "#+end_src";

/// A reviewer cached from pulled comments, so pull can show a name for an email
/// without re-deriving it (the position map and this cache are the only state the
/// tool keeps between runs — DI-3).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Collaborator {
    /// The reviewer's email address (the stable key Google reports).
    pub email: String,
    /// The reviewer's display name.
    pub display_name: String,
}

/// A reply already posted to Google, recorded so `push` does not post it again.
///
/// The operator authors a reply as a `**** REPLY` subheading under a comment; once
/// `push` posts it via `replies.create`, this record (keyed by comment id + the
/// reply text) keeps the post idempotent without rewriting the operator's text
/// (A3). Editing a posted reply's text changes the key and re-posts it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PostedReply {
    /// The `:COMMENT_ID:` of the comment the reply answers.
    pub comment_id: String,
    /// The reply text as authored (the match key).
    pub content: String,
}

/// The decoded `** Sync State` contents.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SyncState {
    /// Where each anchorable element landed in the projected doc (P3 output).
    pub positions: PositionMap,
    /// Reviewers seen on pulled comments.
    pub collaborators: Vec<Collaborator>,
    /// Operator replies already posted to Google (push idempotence).
    pub posted_replies: Vec<PostedReply>,
    /// Fingerprint of the projection last pushed to Google
    /// ([`crate::project::Projection::fingerprint`]). A re-push with a matching
    /// fingerprint skips the full-replace so existing comment anchors survive.
    pub projection_hash: Option<String>,
}

impl SyncState {
    /// Whether a reply with this `comment_id` + `content` has already been posted.
    #[must_use]
    pub fn reply_posted(&self, comment_id: &str, content: &str) -> bool {
        self.posted_replies
            .iter()
            .any(|reply| reply.comment_id == comment_id && reply.content == content)
    }
}

impl SyncState {
    /// Render the full `** Sync State` subtree (heading + source block).
    #[must_use]
    pub fn render_block(&self) -> String {
        format!(
            "{HEADING}\n{SRC_OPEN}\n{}\n{SRC_CLOSE}\n",
            self.to_sexp().render()
        )
    }

    /// Render just the inner s-expression (without the org wrapping).
    #[must_use]
    pub fn to_sexp(&self) -> Sexp {
        let mut items = vec![
            Sexp::symbol("gdoc-sync-state"),
            Sexp::int(VERSION),
            self.positions_sexp(),
            self.collaborators_sexp(),
            self.posted_replies_sexp(),
        ];
        if let Some(hash) = &self.projection_hash {
            items.push(Sexp::list(vec![
                Sexp::symbol("projection-hash"),
                Sexp::string(hash.clone()),
            ]));
        }
        Sexp::list(items)
    }

    /// Parse a [`SyncState`] from a region that may contain the `** Sync State`
    /// block (e.g. the whole `* GDOC_METADATA` machine region).
    ///
    /// A region without the block decodes to [`SyncState::default`].
    ///
    /// # Errors
    ///
    /// Returns [`Error::Sexp`] when the block is present but its s-expression is
    /// malformed or does not match the schema.
    pub fn parse_block(region: &str) -> Result<Self> {
        match extract_inner_sexp(region) {
            None => Ok(Self::default()),
            Some(inner) => {
                let sexp = parse(&inner)?;
                Self::from_sexp(&sexp).map_err(Error::from)
            }
        }
    }

    /// Decode a [`SyncState`] from its inner s-expression.
    ///
    /// # Errors
    ///
    /// Returns [`SexpError::Malformed`] when the shape or schema version is wrong.
    pub fn from_sexp(sexp: &Sexp) -> std::result::Result<Self, SexpError> {
        let items = sexp
            .as_list()
            .ok_or_else(|| malformed("expected (gdoc-sync-state …)"))?;
        if items.first().and_then(Sexp::as_symbol) != Some("gdoc-sync-state") {
            return Err(malformed("expected (gdoc-sync-state …)"));
        }
        match items.get(1).and_then(Sexp::as_int) {
            Some(VERSION) => {}
            Some(other) => {
                return Err(malformed(&format!(
                    "unsupported sync-state version {other}"
                )));
            }
            None => return Err(malformed("sync-state needs a version")),
        }

        let mut state = Self::default();
        for section in items.get(2..).unwrap_or_default() {
            let list = section.as_list().ok_or_else(|| {
                malformed("expected a (positions …) or (collaborators …) section")
            })?;
            let body = list.get(1..).unwrap_or_default();
            match list.first().and_then(Sexp::as_symbol) {
                Some("positions") => decode_positions(body, &mut state.positions)?,
                Some("collaborators") => decode_collaborators(body, &mut state.collaborators)?,
                Some("posted-replies") => decode_posted_replies(body, &mut state.posted_replies)?,
                Some("projection-hash") => {
                    state.projection_hash = body.first().and_then(Sexp::as_str).map(str::to_owned);
                }
                // Unknown sections are ignored for forward compatibility.
                _ => {}
            }
        }
        Ok(state)
    }

    fn positions_sexp(&self) -> Sexp {
        let mut items = Vec::with_capacity(self.positions.len() + 1);
        items.push(Sexp::symbol("positions"));
        for (id, position) in &self.positions {
            items.push(Sexp::list(vec![
                Sexp::symbol("pos"),
                Sexp::string(id.clone()),
                Sexp::int(i64::from(position.index)),
                Sexp::symbol(position.kind.slug()),
            ]));
        }
        Sexp::list(items)
    }

    fn collaborators_sexp(&self) -> Sexp {
        let mut items = Vec::with_capacity(self.collaborators.len() + 1);
        items.push(Sexp::symbol("collaborators"));
        for collaborator in &self.collaborators {
            items.push(Sexp::list(vec![
                Sexp::symbol("collab"),
                Sexp::string(collaborator.email.clone()),
                Sexp::string(collaborator.display_name.clone()),
            ]));
        }
        Sexp::list(items)
    }

    fn posted_replies_sexp(&self) -> Sexp {
        let mut items = Vec::with_capacity(self.posted_replies.len() + 1);
        items.push(Sexp::symbol("posted-replies"));
        for reply in &self.posted_replies {
            items.push(Sexp::list(vec![
                Sexp::symbol("reply"),
                Sexp::string(reply.comment_id.clone()),
                Sexp::string(reply.content.clone()),
            ]));
        }
        Sexp::list(items)
    }
}

/// Decode the `(pos id index kind)` entries of a `(positions …)` section.
fn decode_positions(items: &[Sexp], out: &mut PositionMap) -> std::result::Result<(), SexpError> {
    for item in items {
        let parts = item
            .as_list()
            .ok_or_else(|| malformed("expected (pos id index kind)"))?;
        if parts.first().and_then(Sexp::as_symbol) != Some("pos") {
            return Err(malformed("expected (pos id index kind)"));
        }
        let id = parts
            .get(1)
            .and_then(Sexp::as_str)
            .ok_or_else(|| malformed("pos needs an id"))?;
        let raw_index = parts
            .get(2)
            .and_then(Sexp::as_int)
            .ok_or_else(|| malformed("pos needs an index"))?;
        let index = u32::try_from(raw_index)
            .map_err(|_| malformed("pos index must be a non-negative u32"))?;
        let kind_slug = parts
            .get(3)
            .and_then(Sexp::as_symbol)
            .ok_or_else(|| malformed("pos needs an element kind"))?;
        let kind =
            ElementKind::from_slug(kind_slug).ok_or_else(|| malformed("unknown element kind"))?;
        out.insert(id.to_owned(), Position { index, kind });
    }
    Ok(())
}

/// Decode the `(collab email name)` entries of a `(collaborators …)` section.
fn decode_collaborators(
    items: &[Sexp],
    out: &mut Vec<Collaborator>,
) -> std::result::Result<(), SexpError> {
    for item in items {
        let parts = item
            .as_list()
            .ok_or_else(|| malformed("expected (collab email name)"))?;
        if parts.first().and_then(Sexp::as_symbol) != Some("collab") {
            return Err(malformed("expected (collab email name)"));
        }
        let email = parts
            .get(1)
            .and_then(Sexp::as_str)
            .ok_or_else(|| malformed("collab needs an email"))?;
        let display_name = parts
            .get(2)
            .and_then(Sexp::as_str)
            .ok_or_else(|| malformed("collab needs a display name"))?;
        out.push(Collaborator {
            email: email.to_owned(),
            display_name: display_name.to_owned(),
        });
    }
    Ok(())
}

/// Decode the `(reply comment-id content)` entries of a `(posted-replies …)`
/// section.
fn decode_posted_replies(
    items: &[Sexp],
    out: &mut Vec<PostedReply>,
) -> std::result::Result<(), SexpError> {
    for item in items {
        let parts = item
            .as_list()
            .ok_or_else(|| malformed("expected (reply comment-id content)"))?;
        if parts.first().and_then(Sexp::as_symbol) != Some("reply") {
            return Err(malformed("expected (reply comment-id content)"));
        }
        let comment_id = parts
            .get(1)
            .and_then(Sexp::as_str)
            .ok_or_else(|| malformed("reply needs a comment id"))?;
        let content = parts
            .get(2)
            .and_then(Sexp::as_str)
            .ok_or_else(|| malformed("reply needs content"))?;
        out.push(PostedReply {
            comment_id: comment_id.to_owned(),
            content: content.to_owned(),
        });
    }
    Ok(())
}

/// Extract the s-expression text from the `** Sync State` source block within
/// `region`, or `None` when the block is absent.
fn extract_inner_sexp(region: &str) -> Option<String> {
    let mut lines = region.lines();
    lines.by_ref().find(|line| line.trim_end() == HEADING)?;
    lines.by_ref().find(|line| line.trim() == SRC_OPEN)?;
    let inner: Vec<&str> = lines.take_while(|line| line.trim() != SRC_CLOSE).collect();
    if inner.is_empty() {
        return None;
    }
    Some(inner.join("\n"))
}

fn malformed(message: &str) -> SexpError {
    SexpError::Malformed(message.to_owned())
}

#[cfg(test)]
mod tests {
    use super::{Collaborator, PostedReply, SyncState};
    use crate::project::{ElementKind, Position};

    fn sample() -> SyncState {
        let mut positions = std::collections::BTreeMap::new();
        positions.insert(
            "sec-intro".to_owned(),
            Position {
                index: 1,
                kind: ElementKind::Heading,
            },
        );
        positions.insert(
            "sec-intro/paragraph-1".to_owned(),
            Position {
                index: 5,
                kind: ElementKind::Paragraph,
            },
        );
        SyncState {
            positions,
            collaborators: vec![Collaborator {
                email: "alice@example.com".to_owned(),
                display_name: "Alice Smith".to_owned(),
            }],
            posted_replies: vec![PostedReply {
                comment_id: "C1".to_owned(),
                content: "Fixed, thanks.".to_owned(),
            }],
            projection_hash: Some("deadbeef".to_owned()),
        }
    }

    #[test]
    fn sexp_round_trips_through_text() {
        let state = sample();
        let text = state.to_sexp().render();
        let parsed = crate::sexp::parse(&text).expect("parses");
        let decoded = SyncState::from_sexp(&parsed).expect("decodes");
        assert_eq!(decoded, state);
    }

    #[test]
    fn block_round_trips() {
        let state = sample();
        let block = state.render_block();
        assert!(block.starts_with("** Sync State\n#+begin_src emacs-lisp\n"));
        assert!(block.trim_end().ends_with("#+end_src"));
        let decoded = SyncState::parse_block(&block).expect("decodes");
        assert_eq!(decoded, state);
    }

    #[test]
    fn block_round_trips_within_a_larger_region() {
        let region = format!(
            "* GDOC_METADATA :noexport:\n{}** Active Comments\n",
            sample().render_block()
        );
        let decoded = SyncState::parse_block(&region).expect("decodes");
        assert_eq!(decoded, sample());
    }

    #[test]
    fn missing_block_decodes_to_empty_state() {
        let region = "* GDOC_METADATA :noexport:\n** Active Comments\n";
        let decoded = SyncState::parse_block(region).expect("decodes");
        assert_eq!(decoded, SyncState::default());
    }

    #[test]
    fn empty_state_round_trips() {
        let decoded =
            SyncState::parse_block(&SyncState::default().render_block()).expect("decodes");
        assert_eq!(decoded, SyncState::default());
    }

    #[test]
    fn corrupt_block_is_a_typed_error_not_a_panic() {
        let region = "** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions (pos \"x\"\n#+end_src\n";
        assert!(matches!(
            SyncState::parse_block(region),
            Err(crate::error::Error::Sexp(_))
        ));
    }

    #[test]
    fn unsupported_version_is_rejected() {
        let region = "** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 2 (positions) (collaborators))\n#+end_src\n";
        assert!(matches!(
            SyncState::parse_block(region),
            Err(crate::error::Error::Sexp(_))
        ));
    }

    #[test]
    fn unknown_element_kind_is_rejected() {
        let region = "** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions (pos \"x\" 1 bogus)) (collaborators))\n#+end_src\n";
        assert!(matches!(
            SyncState::parse_block(region),
            Err(crate::error::Error::Sexp(_))
        ));
    }

    #[test]
    fn posted_replies_round_trip_and_dedupe_lookup() {
        let state = sample();
        let decoded = SyncState::parse_block(&state.render_block()).expect("decodes");
        assert_eq!(decoded, state);
        assert!(decoded.reply_posted("C1", "Fixed, thanks."));
        assert!(!decoded.reply_posted("C1", "a different reply"));
        assert!(!decoded.reply_posted("C2", "Fixed, thanks."));
    }

    #[test]
    fn unknown_sections_are_ignored_for_forward_compat() {
        let region = "** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions (pos \"x\" 3 table)) (collaborators) (future-section 9))\n#+end_src\n";
        let decoded = SyncState::parse_block(region).expect("decodes");
        assert_eq!(decoded.positions.len(), 1);
        assert_eq!(
            decoded.positions.get("x"),
            Some(&Position {
                index: 3,
                kind: ElementKind::Table
            })
        );
    }
}
