//! Journal entries — persistent rich-text notes attached to entities,
//! scenes, or standalone. Typed tree, not markdown source, so the wire
//! is sanitisation-proof.
//!
//! Per `docs/SPEC_JOURNAL.md`: v1 ships the typed-block renderer +
//! native editor; Markdown and Typst source blocks are carried on the
//! wire (as `Block::Markdown(_)` / `Block::Typst(_)`) but their
//! renderers are deferred to 0.5.x.

use crate::identity::UserId;
use crate::types::{AssetTag, EntityId};

/// Monotonic id for a journal entry. Allocated server-side.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
pub struct JournalId(pub u64);

/// Who can see this journal entry. Server-enforced on every outbound
/// frame — players never receive entries they don't have visibility for.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum VisibilityScope {
    /// Author only.
    #[default]
    Private,
    /// Author plus the listed users. Author is implicit, need not be
    /// in the list. The director, if distinct from the author, is *not*
    /// implicit — add them explicitly if desired.
    SharedWith(Vec<UserId>),
    /// Every authenticated user with access to the session.
    Public,
}

impl VisibilityScope {
    /// Does `user` see an entry authored by `author` with this scope?
    pub fn visible_to(&self, user: UserId, author: UserId) -> bool {
        if user == author { return true; }
        match self {
            Self::Private          => false,
            Self::Public           => true,
            Self::SharedWith(list) => list.contains(&user),
        }
    }
}

/// A typed rich-text document. Root is always a `NoteContent(Vec<Block>)`.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct NoteContent(pub Vec<Block>);

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub enum Block {
    Paragraph(Vec<Inline>),
    Heading { level: u8, content: Vec<Inline> },
    List   { ordered: bool, items: Vec<Vec<Block>> },
    Quote  (Vec<Block>),
    Code   { language: Option<String>, text: String },
    Divider,
    Image  { asset: AssetTag, alt: String },
    LinkRef(JournalLink),
    /// Markdown source. Renderer deferred to 0.5.x; v1 shows a
    /// monospaced preview of the source.
    Markdown(String),
    /// Typst source. Renderer deferred to 0.5.x (lazy-loaded WASM
    /// compiler); v1 shows a monospaced preview of the source.
    Typst(String),
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub enum Inline {
    Text(String),
    Emphasis(Vec<Inline>),
    Strong(Vec<Inline>),
    InlineCode(String),
    Strike(Vec<Inline>),
    Link { target: LinkTarget, content: Vec<Inline> },
    LineBreak,
}

/// Target of a link — external URL, another journal, an entity, a scene,
/// or a library asset.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub enum LinkTarget {
    External(String),
    Journal(JournalId),
    Entity(EntityId),
    Scene(String),
    Asset(AssetTag),
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct JournalLink {
    pub target: LinkTarget,
    pub label:  String,
}

/// Where a journal entry is attached. Same enum as `LinkTarget` because
/// the affordance is the same: a journal can belong to an entity, a
/// scene, another journal (thread), or nothing at all (free-standing).
pub type JournalAnchor = LinkTarget;

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct JournalEntry {
    pub id:           JournalId,
    pub title:        String,
    pub content:      NoteContent,
    pub visibility:   VisibilityScope,
    pub author:       UserId,
    pub created_at:   u64,
    pub modified_at:  u64,
    /// What this journal is attached to — None for free-standing.
    #[cfg_attr(feature = "serde", serde(default))]
    pub linked_from:  Option<JournalAnchor>,
}

impl JournalEntry {
    /// Extract every `LinkTarget` referenced inside the entry's content.
    /// Used to pre-populate `links_to` for fast reverse-lookup on the
    /// server side.
    pub fn collect_links(&self) -> Vec<LinkTarget> {
        let mut out = Vec::new();
        for block in &self.content.0 {
            collect_from_block(block, &mut out);
        }
        out
    }
}

fn collect_from_block(block: &Block, out: &mut Vec<LinkTarget>) {
    match block {
        Block::Paragraph(inlines) => collect_from_inlines(inlines, out),
        Block::Heading { content, .. } => collect_from_inlines(content, out),
        Block::List { items, .. } => {
            for item in items {
                for b in item { collect_from_block(b, out); }
            }
        }
        Block::Quote(inner) => {
            for b in inner { collect_from_block(b, out); }
        }
        Block::Image { asset, .. } => out.push(LinkTarget::Asset(asset.clone())),
        Block::LinkRef(link) => out.push(link.target.clone()),
        Block::Code { .. } | Block::Divider
        | Block::Markdown(_) | Block::Typst(_) => {}
    }
}

fn collect_from_inlines(inlines: &[Inline], out: &mut Vec<LinkTarget>) {
    for inline in inlines {
        match inline {
            Inline::Text(_) | Inline::InlineCode(_) | Inline::LineBreak => {}
            Inline::Emphasis(inner) | Inline::Strong(inner) | Inline::Strike(inner) => {
                collect_from_inlines(inner, out);
            }
            Inline::Link { target, content } => {
                out.push(target.clone());
                collect_from_inlines(content, out);
            }
        }
    }
}

/// One revision of a journal entry. Immutable; appended on every upsert.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct JournalRevision {
    pub journal_id:  JournalId,
    pub revision:    u32,
    pub author:      UserId,
    pub at_ms:       u64,
    pub content:     NoteContent,
}

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

    #[test]
    fn private_visible_to_author_only() {
        let scope = VisibilityScope::Private;
        let author = UserId(1);
        assert!(scope.visible_to(author, author));
        assert!(!scope.visible_to(UserId(2), author));
    }

    #[test]
    fn public_visible_to_all() {
        let scope = VisibilityScope::Public;
        let author = UserId(1);
        assert!(scope.visible_to(author, author));
        assert!(scope.visible_to(UserId(2), author));
        assert!(scope.visible_to(UserId(99), author));
    }

    #[test]
    fn shared_visible_to_author_and_listed() {
        let scope = VisibilityScope::SharedWith(vec![UserId(2), UserId(3)]);
        let author = UserId(1);
        assert!(scope.visible_to(author, author));
        assert!(scope.visible_to(UserId(2), author));
        assert!(scope.visible_to(UserId(3), author));
        assert!(!scope.visible_to(UserId(4), author));
    }

    #[test]
    fn default_scope_is_private() {
        assert_eq!(VisibilityScope::default(), VisibilityScope::Private);
    }

    #[test]
    fn collect_links_from_content() {
        let entry = JournalEntry {
            id: JournalId(1),
            title: "Test".into(),
            content: NoteContent(vec![
                Block::Paragraph(vec![
                    Inline::Text("see ".into()),
                    Inline::Link {
                        target: LinkTarget::Journal(JournalId(42)),
                        content: vec![Inline::Text("other".into())],
                    },
                ]),
                Block::Image {
                    asset: AssetTag::new("goblin-01"),
                    alt: "goblin".into(),
                },
            ]),
            visibility: VisibilityScope::Public,
            author: UserId(0),
            created_at: 0,
            modified_at: 0,
            linked_from: None,
        };
        let links = entry.collect_links();
        assert_eq!(links.len(), 2);
        assert!(matches!(links[0], LinkTarget::Journal(JournalId(42))));
        assert!(matches!(links[1], LinkTarget::Asset(_)));
    }

    #[cfg(all(test, feature = "wire"))]
    #[test]
    fn note_content_serde_round_trip_via_postcard() {
        let doc = NoteContent(vec![
            Block::Heading { level: 1, content: vec![Inline::Text("Title".into())] },
            Block::Paragraph(vec![
                Inline::Text("Hello ".into()),
                Inline::Strong(vec![Inline::Text("world".into())]),
            ]),
            Block::Divider,
            Block::Markdown("# fallback\n\nsome text".into()),
            Block::Typst("= Equation\n$x^2 + y^2 = z^2$".into()),
        ]);
        let bytes = postcard::to_allocvec(&doc).unwrap();
        let back: NoteContent = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(doc, back);
    }
}
