//! Filesystem issue store.
//!
//! Project-scoped issues live under `<root>/.ideai/tickets/`, one directory per
//! issue number. The Markdown files are the source of truth; `index.json` is a
//! reconstructible projection kept in sync after writes.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;

use domain::{
    AgentIssueRef, Issue, IssueActor, IssueAttachment, IssueAttachmentContent, IssueAttachmentId,
    IssueCarnet, IssueId, IssueIndexEntry, IssueLink, IssueListFilter, IssueNumber,
    IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
    IssueVersion, LocalPath, MarkdownDoc, ProjectPath, SprintId,
};

const IDEAI_DIR: &str = ".ideai";
const ISSUES_DIR: &str = "tickets";
const ISSUE_FILE: &str = "issue.md";
const CARNET_FILE: &str = "carnet.md";
const ATTACHMENTS_DIR: &str = "attachments";
const INDEX_FILE: &str = "index.json";
const COUNTER_FILE: &str = "counter.json";
const COUNTER_LOCK: &str = "counter.lock";
const MUTATION_LOCK: &str = "mutation.lock";
const INDEX_VERSION: u32 = 1;
const ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;

/// Filesystem-backed issue store.
#[derive(Debug, Clone, Default)]
pub struct FsIssueStore;

impl FsIssueStore {
    /// Builds a store.
    #[must_use]
    pub const fn new() -> Self {
        Self
    }
}

/// Filesystem-backed issue number allocator.
#[derive(Debug, Clone, Default)]
pub struct FsIssueNumberAllocator;

impl FsIssueNumberAllocator {
    /// Builds an allocator.
    #[must_use]
    pub const fn new() -> Self {
        Self
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CounterDoc {
    next_number: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexDoc {
    version: u32,
    issues: Vec<IssueIndexEntry>,
}

fn issue_root(root: &ProjectPath) -> PathBuf {
    PathBuf::from(root.as_str())
        .join(IDEAI_DIR)
        .join(ISSUES_DIR)
}

fn issue_dir(root: &ProjectPath, number: IssueNumber) -> PathBuf {
    issue_root(root).join(number.get().to_string())
}

fn issue_path(root: &ProjectPath, number: IssueNumber) -> PathBuf {
    issue_dir(root, number).join(ISSUE_FILE)
}

fn carnet_path(root: &ProjectPath, number: IssueNumber) -> PathBuf {
    issue_dir(root, number).join(CARNET_FILE)
}

fn attachment_path(root: &ProjectPath, number: IssueNumber, relative: &str) -> PathBuf {
    issue_dir(root, number).join(relative)
}

fn index_path(root: &ProjectPath) -> PathBuf {
    issue_root(root).join(INDEX_FILE)
}

fn counter_path(root: &ProjectPath) -> PathBuf {
    issue_root(root).join(COUNTER_FILE)
}

fn counter_lock_path(root: &ProjectPath) -> PathBuf {
    issue_root(root).join(COUNTER_LOCK)
}

fn mutation_lock_path(root: &ProjectPath) -> PathBuf {
    issue_root(root).join(MUTATION_LOCK)
}

async fn acquire_lock(path: &Path, name: &str) -> Result<tokio::fs::File, IssueStoreError> {
    if let Some(parent) = path.parent() {
        tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
    }
    let mut lock = None;
    for _ in 0..50 {
        match tokio::fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(path)
            .await
        {
            Ok(file) => {
                lock = Some(file);
                break;
            }
            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
                tokio::time::sleep(Duration::from_millis(10)).await;
            }
            Err(err) => return Err(io_error(err)),
        }
    }
    let Some(mut lock_file) = lock else {
        return Err(IssueStoreError::Store(format!("{name} lock timed out")));
    };
    lock_file.write_all(b"locked\n").await.map_err(io_error)?;
    Ok(lock_file)
}

async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), IssueStoreError> {
    let parent = path
        .parent()
        .ok_or_else(|| IssueStoreError::Store("path has no parent".to_owned()))?;
    tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
    let tmp = path.with_extension("tmp");
    tokio::fs::write(&tmp, bytes).await.map_err(io_error)?;
    tokio::fs::rename(&tmp, path).await.map_err(io_error)
}

async fn read_string(path: &Path) -> Result<String, IssueStoreError> {
    let bytes = tokio::fs::read(path).await.map_err(|err| {
        if err.kind() == std::io::ErrorKind::NotFound {
            IssueStoreError::NotFound
        } else {
            io_error(err)
        }
    })?;
    String::from_utf8(bytes).map_err(|err| IssueStoreError::Store(err.to_string()))
}

fn io_error(err: std::io::Error) -> IssueStoreError {
    IssueStoreError::Store(err.to_string())
}

fn attachment_by_id(
    issue: &Issue,
    id: &IssueAttachmentId,
) -> Result<IssueAttachment, IssueStoreError> {
    issue
        .attachments
        .iter()
        .find(|attachment| &attachment.id == id)
        .cloned()
        .ok_or(IssueStoreError::NotFound)
}

async fn load_issue(root: &ProjectPath, issue_ref: IssueRef) -> Result<Issue, IssueStoreError> {
    let number = issue_ref.number();
    let issue_text = read_string(&issue_path(root, number)).await?;
    let mut issue = parse_issue_doc(&issue_text)?;
    let carnet = match read_string(&carnet_path(root, number)).await {
        Ok(text) => parse_carnet_doc(&text)?.carnet,
        Err(IssueStoreError::NotFound) => MarkdownDoc::default(),
        Err(err) => return Err(err),
    };
    issue.carnet = carnet;
    Issue::rehydrate(issue).map_err(|err| IssueStoreError::Invalid(err.to_string()))
}

async fn save_issue(root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
    let dir = issue_dir(root, issue.number);
    tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
    write_atomic(&dir.join(ISSUE_FILE), render_issue_doc(issue).as_bytes()).await?;
    write_atomic(&dir.join(CARNET_FILE), render_carnet_doc(issue).as_bytes()).await
}

async fn rebuild_index(root: &ProjectPath) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
    let dir = issue_root(root);
    let mut rows = Vec::new();
    let mut entries = match tokio::fs::read_dir(&dir).await {
        Ok(entries) => entries,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            write_index(root, &rows).await?;
            return Ok(rows);
        }
        Err(err) => return Err(io_error(err)),
    };
    while let Some(entry) = entries.next_entry().await.map_err(io_error)? {
        let Ok(file_type) = entry.file_type().await else {
            continue;
        };
        if !file_type.is_dir() {
            continue;
        }
        let name = entry.file_name().to_string_lossy().to_string();
        let Ok(number) = name.parse::<u64>() else {
            continue;
        };
        let Ok(number) = IssueNumber::new(number) else {
            continue;
        };
        if let Ok(issue) = load_issue(root, IssueRef::from(number)).await {
            rows.push(IssueIndexEntry::from(&issue));
        }
    }
    rows.sort_by_key(|row| row.issue_ref.number().get());
    write_index(root, &rows).await?;
    Ok(rows)
}

async fn read_index(root: &ProjectPath) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
    match tokio::fs::read(index_path(root)).await {
        Ok(bytes) => {
            let doc: IndexDoc = serde_json::from_slice(&bytes)
                .map_err(|err| IssueStoreError::Store(err.to_string()))?;
            Ok(doc.issues)
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => rebuild_index(root).await,
        Err(err) => Err(io_error(err)),
    }
}

async fn write_index(root: &ProjectPath, rows: &[IssueIndexEntry]) -> Result<(), IssueStoreError> {
    let doc = IndexDoc {
        version: INDEX_VERSION,
        issues: rows.to_vec(),
    };
    let bytes =
        serde_json::to_vec_pretty(&doc).map_err(|err| IssueStoreError::Store(err.to_string()))?;
    write_atomic(&index_path(root), &bytes).await
}

fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
    if !filter.statuses.is_empty() && !filter.statuses.contains(&row.status) {
        return false;
    }
    if !filter.priorities.is_empty() && !filter.priorities.contains(&row.priority) {
        return false;
    }
    if let Some(agent_id) = filter.assigned_agent_id {
        if !row.assigned_agent_ids.contains(&agent_id) {
            return false;
        }
    }
    if let Some(created_by) = &filter.created_by {
        if &row.created_by != created_by {
            return false;
        }
    }
    if filter
        .sprint
        .is_some_and(|sprint| row.sprint != Some(sprint))
    {
        return false;
    }
    if let Some(text) = filter
        .text
        .as_ref()
        .map(|s| s.trim())
        .filter(|s| !s.is_empty())
    {
        if let Some(number) = parse_issue_search_ref(text) {
            return row.issue_ref.number() == number;
        }
        row.title
            .to_ascii_lowercase()
            .contains(&text.to_ascii_lowercase())
    } else {
        true
    }
}

fn parse_issue_search_ref(needle: &str) -> Option<IssueNumber> {
    let trimmed = needle.trim();
    let raw = trimmed.strip_prefix('#').unwrap_or(trimmed);
    if raw.is_empty() || !raw.chars().all(|ch| ch.is_ascii_digit()) {
        return None;
    }
    raw.parse::<u64>()
        .ok()
        .and_then(|number| IssueNumber::new(number).ok())
}

#[async_trait]
impl IssueStore for FsIssueStore {
    async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
        let lock_path = mutation_lock_path(root);
        let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
        let result = async {
            issue
                .validate()
                .map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
            if tokio::fs::try_exists(issue_path(root, issue.number))
                .await
                .map_err(io_error)?
            {
                return Err(IssueStoreError::Invalid(format!(
                    "issue {} already exists",
                    issue.reference()
                )));
            }
            save_issue(root, issue).await?;
            rebuild_index(root).await?;
            Ok(())
        }
        .await;
        let _ = tokio::fs::remove_file(&lock_path).await;
        result
    }

    async fn get_by_ref(
        &self,
        root: &ProjectPath,
        issue_ref: IssueRef,
    ) -> Result<Issue, IssueStoreError> {
        load_issue(root, issue_ref).await
    }

    async fn list(
        &self,
        root: &ProjectPath,
        filter: IssueListFilter,
    ) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
        let rows = read_index(root).await?;
        if filter
            .text
            .as_ref()
            .is_some_and(|text| !text.trim().is_empty())
        {
            // Text search may need description/carnet, so fall back to source files.
            let raw_needle = filter.text.as_ref().unwrap().trim();
            let ref_needle = parse_issue_search_ref(raw_needle);
            let needle = raw_needle.to_ascii_lowercase();
            let base_filter = IssueListFilter {
                text: None,
                ..filter
            };
            let mut out = Vec::new();
            for row in rows {
                if !filter_matches(&row, &base_filter) {
                    continue;
                }
                if let Some(number) = ref_needle {
                    if row.issue_ref.number() == number {
                        out.push(row);
                    }
                    continue;
                }
                if row.title.to_ascii_lowercase().contains(&needle) {
                    out.push(row);
                    continue;
                }
                if let Ok(issue) = load_issue(root, row.issue_ref).await {
                    if issue_contains(&issue, &needle) {
                        out.push(row);
                    }
                }
            }
            return Ok(out);
        }
        let mut rows = rows;
        rows.retain(|row| filter_matches(row, &filter));
        Ok(rows)
    }

    async fn update(
        &self,
        root: &ProjectPath,
        issue: &Issue,
        expected_version: IssueVersion,
    ) -> Result<(), IssueStoreError> {
        let lock_path = mutation_lock_path(root);
        let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
        let result = async {
            issue
                .validate()
                .map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
            let current = load_issue(root, issue.reference()).await?;
            if current.version != expected_version {
                return Err(IssueStoreError::VersionConflict {
                    expected: expected_version,
                    actual: current.version,
                });
            }
            save_issue(root, issue).await?;
            rebuild_index(root).await?;
            Ok(())
        }
        .await;
        let _ = tokio::fs::remove_file(&lock_path).await;
        result
    }

    async fn delete(&self, root: &ProjectPath, issue_ref: IssueRef) -> Result<(), IssueStoreError> {
        let lock_path = mutation_lock_path(root);
        let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
        let result = async {
            if !tokio::fs::try_exists(issue_path(root, issue_ref.number()))
                .await
                .map_err(io_error)?
            {
                return Err(IssueStoreError::NotFound);
            }
            tokio::fs::remove_dir_all(issue_dir(root, issue_ref.number()))
                .await
                .map_err(io_error)?;
            rebuild_index(root).await?;
            Ok(())
        }
        .await;
        let _ = tokio::fs::remove_file(&lock_path).await;
        result
    }

    async fn read_carnet(
        &self,
        root: &ProjectPath,
        issue_ref: IssueRef,
    ) -> Result<IssueCarnet, IssueStoreError> {
        let text = read_string(&carnet_path(root, issue_ref.number())).await?;
        parse_carnet_doc(&text)
    }

    async fn write_carnet(
        &self,
        root: &ProjectPath,
        issue_ref: IssueRef,
        carnet: MarkdownDoc,
        actor: IssueActor,
        now_ms: u64,
        expected_version: IssueVersion,
    ) -> Result<IssueCarnet, IssueStoreError> {
        let lock_path = mutation_lock_path(root);
        let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
        let result = async {
            let current = load_issue(root, issue_ref).await?;
            if current.version != expected_version {
                return Err(IssueStoreError::VersionConflict {
                    expected: expected_version,
                    actual: current.version,
                });
            }
            let updated = current
                .mutate(actor, now_ms, |issue| issue.carnet = carnet)
                .map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
            save_issue(root, &updated).await?;
            rebuild_index(root).await?;
            self.read_carnet(root, issue_ref).await
        }
        .await;
        let _ = tokio::fs::remove_file(&lock_path).await;
        result
    }

    async fn add_attachment_from_path(
        &self,
        root: &ProjectPath,
        issue_ref: IssueRef,
        source: &LocalPath,
        attachment_id: IssueAttachmentId,
        filename: String,
        mime: String,
        actor: IssueActor,
        now_ms: u64,
        expected_version: IssueVersion,
    ) -> Result<Issue, IssueStoreError> {
        let source_path = PathBuf::from(source.as_str());
        let source_meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?;
        if !source_meta.is_file() {
            return Err(IssueStoreError::Invalid(
                "attachment source must be a file".to_owned(),
            ));
        }
        let size_bytes = source_meta.len();
        if size_bytes > ATTACHMENT_MAX_BYTES {
            return Err(IssueStoreError::Invalid(format!(
                "attachment exceeds {} bytes",
                ATTACHMENT_MAX_BYTES
            )));
        }
        let stored_name = format!("{}-{}", attachment_id.as_str(), filename);
        let attachment = IssueAttachment {
            id: attachment_id,
            filename,
            path: format!("{ATTACHMENTS_DIR}/{stored_name}"),
            mime,
            size_bytes,
            added_by: actor,
            added_at: now_ms,
            summarized_in_carnet: false,
            summarized_by: None,
            summarized_at: None,
        };

        let lock_path = mutation_lock_path(root);
        let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
        let result = async {
            let current = load_issue(root, issue_ref).await?;
            if current.version != expected_version {
                return Err(IssueStoreError::VersionConflict {
                    expected: expected_version,
                    actual: current.version,
                });
            }
            if current
                .attachments
                .iter()
                .any(|existing| existing.id == attachment.id)
            {
                return Err(IssueStoreError::Invalid(format!(
                    "attachment {} already exists",
                    attachment.id.as_str()
                )));
            }

            let dest = attachment_path(root, issue_ref.number(), &attachment.path);
            let parent = dest.parent().ok_or_else(|| {
                IssueStoreError::Store("attachment path has no parent".to_owned())
            })?;
            tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
            tokio::fs::copy(&source_path, &dest)
                .await
                .map_err(io_error)?;

            let updated = current
                .mutate(attachment.added_by.clone(), attachment.added_at, |issue| {
                    issue.attachments.push(attachment);
                })
                .map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
            save_issue(root, &updated).await?;
            rebuild_index(root).await?;
            Ok(updated)
        }
        .await;
        let _ = tokio::fs::remove_file(&lock_path).await;
        result
    }

    async fn read_attachment(
        &self,
        root: &ProjectPath,
        issue_ref: IssueRef,
        attachment_id: &IssueAttachmentId,
    ) -> Result<IssueAttachmentContent, IssueStoreError> {
        let issue = load_issue(root, issue_ref).await?;
        let attachment = attachment_by_id(&issue, attachment_id)?;
        let bytes = tokio::fs::read(attachment_path(root, issue.number, &attachment.path))
            .await
            .map_err(|err| {
                if err.kind() == std::io::ErrorKind::NotFound {
                    IssueStoreError::NotFound
                } else {
                    io_error(err)
                }
            })?;
        Ok(IssueAttachmentContent { attachment, bytes })
    }

    async fn mark_attachment_summarized(
        &self,
        root: &ProjectPath,
        issue_ref: IssueRef,
        attachment_id: &IssueAttachmentId,
        actor: IssueActor,
        now_ms: u64,
        expected_version: IssueVersion,
    ) -> Result<Issue, IssueStoreError> {
        let lock_path = mutation_lock_path(root);
        let _lock_file = acquire_lock(&lock_path, "issue mutation").await?;
        let result = async {
            let current = load_issue(root, issue_ref).await?;
            if current.version != expected_version {
                return Err(IssueStoreError::VersionConflict {
                    expected: expected_version,
                    actual: current.version,
                });
            }
            if !current
                .attachments
                .iter()
                .any(|attachment| &attachment.id == attachment_id)
            {
                return Err(IssueStoreError::NotFound);
            }
            let updated = current
                .mutate(actor.clone(), now_ms, |issue| {
                    if let Some(attachment) = issue
                        .attachments
                        .iter_mut()
                        .find(|attachment| &attachment.id == attachment_id)
                    {
                        attachment.summarized_in_carnet = true;
                        attachment.summarized_by = Some(actor);
                        attachment.summarized_at = Some(now_ms);
                    }
                })
                .map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
            save_issue(root, &updated).await?;
            rebuild_index(root).await?;
            Ok(updated)
        }
        .await;
        let _ = tokio::fs::remove_file(&lock_path).await;
        result
    }
}

#[async_trait]
impl IssueNumberAllocator for FsIssueNumberAllocator {
    async fn allocate_next(&self, root: &ProjectPath) -> Result<IssueNumber, IssueStoreError> {
        let dir = issue_root(root);
        tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
        let lock_path = counter_lock_path(root);
        let _lock_file = acquire_lock(&lock_path, "issue number allocator").await?;

        let result = async {
            let path = counter_path(root);
            let current = match tokio::fs::read(&path).await {
                Ok(bytes) => {
                    serde_json::from_slice::<CounterDoc>(&bytes)
                        .map_err(|err| IssueStoreError::Store(err.to_string()))?
                        .next_number
                }
                Err(err) if err.kind() == std::io::ErrorKind::NotFound => 1,
                Err(err) => return Err(io_error(err)),
            };
            let allocated = IssueNumber::new(current)
                .map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
            let next = CounterDoc {
                next_number: current + 1,
            };
            let bytes = serde_json::to_vec_pretty(&next)
                .map_err(|err| IssueStoreError::Store(err.to_string()))?;
            write_atomic(&path, &bytes).await?;
            Ok(allocated)
        }
        .await;

        let _ = tokio::fs::remove_file(&lock_path).await;
        result
    }
}

fn issue_contains(issue: &Issue, needle: &str) -> bool {
    issue
        .description
        .as_str()
        .to_ascii_lowercase()
        .contains(needle)
        || issue.carnet.as_str().to_ascii_lowercase().contains(needle)
}

fn render_issue_doc(issue: &Issue) -> String {
    format!(
        "---\n\
id: {}\n\
number: {}\n\
title: {}\n\
status: {}\n\
priority: {}\n\
sprint: {}\n\
links: {}\n\
agentRefs: {}\n\
attachments: {}\n\
createdBy: {}\n\
updatedBy: {}\n\
createdAt: {}\n\
updatedAt: {}\n\
version: {}\n\
---\n{}",
        json_string(&issue.id.to_string()),
        issue.number.get(),
        json_string(&issue.title),
        json_value(&issue.status),
        json_value(&issue.priority),
        json_value(&issue.sprint),
        json_value(&issue.links),
        json_value(&issue.agent_refs),
        json_value(&issue.attachments),
        json_value(&issue.created_by),
        json_value(&issue.updated_by),
        issue.created_at,
        issue.updated_at,
        issue.version.get(),
        issue.description.as_str()
    )
}

fn render_carnet_doc(issue: &Issue) -> String {
    format!(
        "---\n\
issueRef: {}\n\
version: {}\n\
updatedBy: {}\n\
updatedAt: {}\n\
---\n{}",
        json_string(&issue.reference().to_string()),
        issue.version.get(),
        json_value(&issue.updated_by),
        issue.updated_at,
        issue.carnet.as_str()
    )
}

fn parse_issue_doc(text: &str) -> Result<Issue, IssueStoreError> {
    let (fm, body) = split_frontmatter(text)?;
    let map = parse_frontmatter_map(fm)?;
    let id_raw: String = parse_json_field(&map, "id")?;
    let id = IssueId::from_uuid(
        uuid::Uuid::parse_str(&id_raw).map_err(|err| IssueStoreError::Invalid(err.to_string()))?,
    );
    let number = IssueNumber::new(parse_plain_u64(&map, "number")?)
        .map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
    let title: String = parse_json_field(&map, "title")?;
    let status: IssueStatus = parse_json_field(&map, "status")?;
    let priority: IssuePriority = parse_json_field(&map, "priority")?;
    let sprint: Option<SprintId> = parse_optional_json_field(&map, "sprint")?.unwrap_or(None);
    let links: Vec<IssueLink> = parse_json_field(&map, "links")?;
    let agent_refs: Vec<AgentIssueRef> = parse_json_field(&map, "agentRefs")?;
    let attachments: Vec<IssueAttachment> =
        parse_optional_json_field(&map, "attachments")?.unwrap_or_default();
    let created_by: IssueActor = parse_json_field(&map, "createdBy")?;
    let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
    let created_at = parse_plain_u64(&map, "createdAt")?;
    let updated_at = parse_plain_u64(&map, "updatedAt")?;
    let version = IssueVersion::new(parse_plain_u64(&map, "version")?)
        .map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
    Issue::rehydrate(Issue {
        id,
        number,
        title,
        description: MarkdownDoc::new(body),
        status,
        priority,
        sprint,
        carnet: MarkdownDoc::default(),
        links,
        agent_refs,
        attachments,
        created_by,
        updated_by,
        created_at,
        updated_at,
        version,
    })
    .map_err(|err| IssueStoreError::Invalid(err.to_string()))
}

fn parse_carnet_doc(text: &str) -> Result<IssueCarnet, IssueStoreError> {
    let (fm, body) = split_frontmatter(text)?;
    let map = parse_frontmatter_map(fm)?;
    let raw_ref: String = parse_json_field(&map, "issueRef")?;
    let issue_ref =
        IssueRef::from_str(&raw_ref).map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
    let version = IssueVersion::new(parse_plain_u64(&map, "version")?)
        .map_err(|err| IssueStoreError::Invalid(err.to_string()))?;
    let updated_by: IssueActor = parse_json_field(&map, "updatedBy")?;
    let updated_at = parse_plain_u64(&map, "updatedAt")?;
    Ok(IssueCarnet {
        issue_ref,
        carnet: MarkdownDoc::new(body),
        version,
        updated_by,
        updated_at,
    })
}

fn split_frontmatter(text: &str) -> Result<(&str, &str), IssueStoreError> {
    let rest = text
        .strip_prefix("---\n")
        .or_else(|| text.strip_prefix("---\r\n"))
        .ok_or_else(|| IssueStoreError::Invalid("missing opening frontmatter fence".to_owned()))?;
    let mut offset = 0;
    for line in rest.split_inclusive('\n') {
        if line.trim_end_matches(['\r', '\n']) == "---" {
            let body_start = offset + line.len();
            return Ok((&rest[..offset], &rest[body_start..]));
        }
        offset += line.len();
    }
    Err(IssueStoreError::Invalid(
        "missing closing frontmatter fence".to_owned(),
    ))
}

fn parse_frontmatter_map(block: &str) -> Result<BTreeMap<String, String>, IssueStoreError> {
    let mut map = BTreeMap::new();
    for line in block.lines().filter(|line| !line.trim().is_empty()) {
        let (key, value) = line
            .split_once(':')
            .ok_or_else(|| IssueStoreError::Invalid("frontmatter line missing ':'".to_owned()))?;
        map.insert(key.trim().to_owned(), value.trim().to_owned());
    }
    Ok(map)
}

fn parse_plain_u64(map: &BTreeMap<String, String>, key: &str) -> Result<u64, IssueStoreError> {
    map.get(key)
        .ok_or_else(|| IssueStoreError::Invalid(format!("missing frontmatter key `{key}`")))?
        .parse::<u64>()
        .map_err(|err| IssueStoreError::Invalid(err.to_string()))
}

fn parse_json_field<T: serde::de::DeserializeOwned>(
    map: &BTreeMap<String, String>,
    key: &str,
) -> Result<T, IssueStoreError> {
    let value = map
        .get(key)
        .ok_or_else(|| IssueStoreError::Invalid(format!("missing frontmatter key `{key}`")))?;
    serde_json::from_str(value).map_err(|err| IssueStoreError::Invalid(err.to_string()))
}

fn parse_optional_json_field<T: serde::de::DeserializeOwned>(
    map: &BTreeMap<String, String>,
    key: &str,
) -> Result<Option<T>, IssueStoreError> {
    let Some(value) = map.get(key) else {
        return Ok(None);
    };
    serde_json::from_str(value)
        .map(Some)
        .map_err(|err| IssueStoreError::Invalid(err.to_string()))
}

fn json_value<T: Serialize>(value: &T) -> String {
    serde_json::to_string(value).expect("serializing issue frontmatter cannot fail")
}

fn json_string(value: &str) -> String {
    serde_json::to_string(value).expect("serializing issue string cannot fail")
}
