use std::path::PathBuf;

use chrono::DateTime;
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadMemoryMode as MemoryMode;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::Serializer;

mod optional_option {
    use super::*;

    pub fn serialize<T, S>(value: &Option<Option<T>>, serializer: S) -> Result<S::Ok, S::Error>
    where
        T: Serialize,
        S: Serializer,
    {
        match value {
            Some(value) => value.serialize(serializer),
            None => serializer.serialize_none(),
        }
    }

    pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
    where
        T: Deserialize<'de>,
        D: Deserializer<'de>,
    {
        Option::<T>::deserialize(deserializer).map(Some)
    }
}

/// Thread-scoped metadata used when opening live persistence.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThreadPersistenceMetadata {
    /// Effective working directory for environment-backed threads.
    ///
    /// `None` means the thread has no filesystem/environment context.
    pub cwd: Option<PathBuf>,
    /// Model provider associated with the thread.
    pub model_provider: String,
    /// Memory mode associated with the live thread.
    pub memory_mode: MemoryMode,
}

/// Extra configuration fields for a thread.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExtraConfig {}

/// Parameters required to create a persisted thread.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateThreadParams {
    /// Thread id generated by Codex before opening persistence.
    pub thread_id: ThreadId,
    /// Optional extra configuration fields for the thread.
    pub extra_config: Option<ExtraConfig>,
    /// Source thread id when this thread is created as a fork.
    pub forked_from_id: Option<ThreadId>,
    /// The ID of the parent thread. This will only be set if this thread is a subagent.
    pub parent_thread_id: Option<ThreadId>,
    /// Runtime source for the thread.
    pub source: SessionSource,
    /// Optional analytics source classification for this thread.
    pub thread_source: Option<ThreadSource>,
    /// Base instructions persisted in session metadata.
    pub base_instructions: BaseInstructions,
    /// Dynamic tools available to the thread at startup.
    pub dynamic_tools: Vec<DynamicToolSpec>,
    /// Multi-agent runtime selected when the thread was created.
    pub multi_agent_version: Option<MultiAgentVersion>,
    /// Metadata captured for the newly created thread.
    pub metadata: ThreadPersistenceMetadata,
}

/// Parameters required to reopen persistence for an existing thread.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ResumeThreadParams {
    /// Existing thread id whose future items should be appended.
    pub thread_id: ThreadId,
    /// Known local rollout path when the caller resumed from a specific file.
    pub rollout_path: Option<PathBuf>,
    /// Known replay history for the resumed thread, if already loaded by the caller.
    pub history: Option<Vec<RolloutItem>>,
    /// Whether archived threads may be reopened.
    pub include_archived: bool,
    /// Metadata for future writes appended to the resumed live thread.
    pub metadata: ThreadPersistenceMetadata,
}

/// Parameters for appending rollout items to a live thread.
#[derive(Clone, Debug)]
pub struct AppendThreadItemsParams {
    /// Thread id to append to.
    pub thread_id: ThreadId,
    /// Raw rollout items to append in order.
    ///
    /// Store implementations are responsible for applying the shared rollout persistence policy
    /// before writing durable replay history or any implementation-owned projections.
    pub items: Vec<RolloutItem>,
}

/// Parameters for loading persisted history for resume, fork, rollback, and memory jobs.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoadThreadHistoryParams {
    /// Thread id to load.
    pub thread_id: ThreadId,
    /// Whether archived threads are eligible.
    pub include_archived: bool,
}

/// Persisted rollout history for a thread, without any filesystem path requirement.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StoredThreadHistory {
    /// Thread id represented by the history.
    pub thread_id: ThreadId,
    /// Persisted rollout items in replay order.
    pub items: Vec<RolloutItem>,
}

/// Parameters for reading a thread summary and optionally its replay history.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReadThreadParams {
    /// Thread id to read.
    pub thread_id: ThreadId,
    /// Whether archived threads are eligible.
    pub include_archived: bool,
    /// Whether persisted rollout items should be included in the response.
    pub include_history: bool,
}

/// Parameters for reading a local rollout-backed thread by path.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReadThreadByRolloutPathParams {
    /// Local rollout JSONL path to read.
    pub rollout_path: PathBuf,
    /// Whether archived threads are eligible.
    pub include_archived: bool,
    /// Whether persisted rollout items should be included in the response.
    pub include_history: bool,
}

/// The sort key to use when listing stored threads.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ThreadSortKey {
    /// Sort by the thread creation timestamp.
    #[default]
    CreatedAt,
    /// Sort by the thread last-update timestamp.
    UpdatedAt,
}

/// The direction to use when listing stored threads.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SortDirection {
    /// Return older threads before newer threads.
    Asc,
    /// Return newer threads before older threads.
    #[default]
    Desc,
}

/// Parameters for listing threads.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListThreadsParams {
    /// Maximum number of threads to return.
    pub page_size: usize,
    /// Opaque cursor returned by a previous list call.
    pub cursor: Option<String>,
    /// Sort order requested by the caller.
    pub sort_key: ThreadSortKey,
    /// Sort direction requested by the caller.
    pub sort_direction: SortDirection,
    /// Allowed session sources. Empty means implementation default.
    pub allowed_sources: Vec<SessionSource>,
    /// Optional model provider filter. `None` means implementation default, while an empty vector
    /// means all providers.
    pub model_providers: Option<Vec<String>>,
    /// Optional cwd filters. `None` means all working directories, while an empty vector matches no
    /// threads.
    pub cwd_filters: Option<Vec<PathBuf>>,
    /// Whether archived threads should be listed instead of active threads.
    pub archived: bool,
    /// Optional substring/full-text search term for thread title/preview.
    pub search_term: Option<String>,
    /// Return directly from the state DB without scanning JSONL rollouts to repair metadata.
    pub use_state_db_only: bool,
}

/// Parameters for searching thread content.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SearchThreadsParams {
    /// Maximum number of threads to return.
    pub page_size: usize,
    /// Opaque cursor returned by a previous search call.
    pub cursor: Option<String>,
    /// Sort order requested by the caller.
    pub sort_key: ThreadSortKey,
    /// Sort direction requested by the caller.
    pub sort_direction: SortDirection,
    /// Allowed session sources. Empty means implementation default.
    pub allowed_sources: Vec<SessionSource>,
    /// Whether archived threads should be searched instead of active threads.
    pub archived: bool,
    /// Visible thread content to search for.
    pub search_term: String,
}

/// A page of stored thread records.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ThreadPage {
    /// Threads returned for this page.
    pub items: Vec<StoredThread>,
    /// Opaque cursor to continue listing.
    pub next_cursor: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StoredThreadSearchResult {
    pub thread: StoredThread,
    pub snippet: String,
}

/// A page of thread-search results.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ThreadSearchPage {
    /// Search results returned for this page.
    pub items: Vec<StoredThreadSearchResult>,
    /// Opaque cursor to continue searching.
    pub next_cursor: Option<String>,
}

/// Requested amount of item detail for stored turns.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum StoredTurnItemsView {
    /// Return turn metadata only.
    NotLoaded,
    /// Return display summary items for each turn.
    #[default]
    Summary,
    /// Return every persisted item available for each turn.
    Full,
}

/// Store-owned status for a persisted turn.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum StoredTurnStatus {
    /// The turn completed normally.
    Completed,
    /// The turn was interrupted before normal completion.
    Interrupted,
    /// The turn failed.
    Failed,
    /// The turn is still in progress.
    InProgress,
}

/// Store-owned error details for a failed persisted turn.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredTurnError {
    /// User-visible error message.
    pub message: String,
    /// Optional additional detail for clients that expose expanded error context.
    pub additional_details: Option<String>,
}

/// Parameters for listing turns within a stored thread.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListTurnsParams {
    /// Thread id to read.
    pub thread_id: ThreadId,
    /// Whether archived threads are eligible.
    pub include_archived: bool,
    /// Opaque cursor returned by a previous list call.
    pub cursor: Option<String>,
    /// Maximum number of turns to return.
    pub page_size: usize,
    /// Sort direction requested by the caller.
    pub sort_direction: SortDirection,
    /// Requested amount of item detail for each returned turn.
    pub items_view: StoredTurnItemsView,
}

/// Store-owned turn representation used by turn pagination APIs.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StoredTurn {
    /// Turn id.
    pub turn_id: String,
    /// Persisted rollout items associated with this turn, according to `items_view`.
    pub items: Vec<RolloutItem>,
    /// Opaque serialized turn metadata supplied by a projected durable store.
    pub metadata_json: Option<Vec<u8>>,
    /// Semantic turn creation timestamp in milliseconds, when supplied by a projected durable
    /// store.
    pub turn_created_at_ms: Option<i64>,
    /// Amount of item detail included in `items`.
    pub items_view: StoredTurnItemsView,
    /// Store-owned status for API layer projection.
    pub status: StoredTurnStatus,
    /// Error message when the turn failed.
    pub error: Option<StoredTurnError>,
    /// Unix timestamp (seconds) when the turn started.
    pub started_at: Option<i64>,
    /// Unix timestamp (seconds) when the turn completed.
    pub completed_at: Option<i64>,
    /// Duration between turn start and completion in milliseconds, if known.
    pub duration_ms: Option<i64>,
}

/// A page of stored turns.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TurnPage {
    /// Turns returned for this page.
    pub turns: Vec<StoredTurn>,
    /// Opaque cursor to continue listing.
    pub next_cursor: Option<String>,
    /// Opaque cursor for fetching in the opposite direction.
    pub backwards_cursor: Option<String>,
}

/// Parameters for listing persisted items within a single turn.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListItemsParams {
    /// Thread id to read.
    pub thread_id: ThreadId,
    /// Turn id to hydrate.
    pub turn_id: String,
    /// Whether archived threads are eligible.
    pub include_archived: bool,
    /// Opaque cursor returned by a previous list call.
    pub cursor: Option<String>,
    /// Maximum number of items to return.
    pub page_size: usize,
    /// Sort direction requested by the caller.
    pub sort_direction: SortDirection,
}

/// A projected app-server `ThreadItem` snapshot within a turn.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredThreadItem {
    pub turn_id: Option<String>,
    pub item_key: String,
    pub item_ordinal: u64,
    pub item_created_at_ms: i64,
    pub materialized_thread_item_json: Vec<u8>,
}

/// A page of persisted items within a turn.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ItemPage {
    /// Items returned for this page.
    pub items: Vec<StoredThreadItem>,
    /// Opaque cursor to continue listing.
    pub next_cursor: Option<String>,
    /// Opaque cursor for fetching in the opposite direction.
    pub backwards_cursor: Option<String>,
}

/// Store-owned thread metadata used by list/read/resume responses.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StoredThread {
    /// Thread id.
    pub thread_id: ThreadId,
    /// Optional extra configuration fields for the thread.
    pub extra_config: Option<ExtraConfig>,
    /// Local rollout path when the backing store is filesystem-based.
    pub rollout_path: Option<PathBuf>,
    /// Source thread id when this thread was forked from another thread.
    pub forked_from_id: Option<ThreadId>,
    /// The ID of the parent thread. This will only be set if this thread is a subagent.
    pub parent_thread_id: Option<ThreadId>,
    /// Best available user-facing preview, usually the first user message.
    pub preview: String,
    /// Optional user-facing thread name/title.
    pub name: Option<String>,
    /// Model provider id associated with the thread.
    pub model_provider: String,
    /// Latest observed model, if known.
    pub model: Option<String>,
    /// Latest observed reasoning effort, if known.
    pub reasoning_effort: Option<ReasoningEffort>,
    /// Thread creation timestamp.
    pub created_at: DateTime<Utc>,
    /// Thread last-update timestamp.
    pub updated_at: DateTime<Utc>,
    /// Thread archive timestamp, if archived.
    pub archived_at: Option<DateTime<Utc>>,
    /// Working directory captured for the thread.
    pub cwd: PathBuf,
    /// CLI version captured for the thread.
    pub cli_version: String,
    /// Runtime source for the thread.
    pub source: SessionSource,
    /// Optional analytics source classification for this thread.
    pub thread_source: Option<ThreadSource>,
    /// Optional random nickname for thread-spawn sub-agents.
    pub agent_nickname: Option<String>,
    /// Optional role for thread-spawn sub-agents.
    pub agent_role: Option<String>,
    /// Optional canonical path for thread-spawn sub-agents.
    pub agent_path: Option<String>,
    /// Optional Git metadata captured for the thread.
    pub git_info: Option<GitInfo>,
    /// Approval mode captured for the thread.
    pub approval_mode: AskForApproval,
    /// Canonical runtime permissions captured for the thread.
    pub permission_profile: PermissionProfile,
    /// Last observed token usage.
    pub token_usage: Option<TokenUsage>,
    /// First user message observed for this thread, if any.
    pub first_user_message: Option<String>,
    /// Persisted history, populated only when requested.
    pub history: Option<StoredThreadHistory>,
}

/// Optional field patch where omission leaves a value unchanged and `Some(None)` clears it.
pub type ClearableField<T> = Option<Option<T>>;

/// Patch for thread Git metadata.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitInfoPatch {
    /// Replacement commit SHA, clear request, or no-op.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        with = "optional_option"
    )]
    pub sha: ClearableField<String>,
    /// Replacement branch name, clear request, or no-op.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        with = "optional_option"
    )]
    pub branch: ClearableField<String>,
    /// Replacement origin URL, clear request, or no-op.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        with = "optional_option"
    )]
    pub origin_url: ClearableField<String>,
}

impl GitInfoPatch {
    /// Merges another patch into this one using field-presence semantics.
    ///
    /// Omitted fields in `next` leave the current patch unchanged. Present fields replace the
    /// current value, including clear requests like `Some(None)`.
    pub fn merge(&mut self, next: Self) {
        if next.sha.is_some() {
            self.sha = next.sha;
        }
        if next.branch.is_some() {
            self.branch = next.branch;
        }
        if next.origin_url.is_some() {
            self.origin_url = next.origin_url;
        }
    }
}

/// Patch for thread metadata.
///
/// Every field is literal: `None` leaves that field unchanged, while `Some`
/// applies the supplied value. Fields whose value may itself be cleared use an
/// inner `Option`, where `Some(None)` clears the field.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ThreadMetadataPatch {
    /// Replacement user-facing thread name.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        with = "optional_option"
    )]
    pub name: ClearableField<String>,
    /// Known local rollout path for stores that expose one.
    pub rollout_path: Option<PathBuf>,
    /// Best available preview text for discovery/listing.
    pub preview: Option<String>,
    /// Best-effort title derived from history.
    pub title: Option<String>,
    /// Model provider associated with the thread.
    pub model_provider: Option<String>,
    /// Latest observed model.
    pub model: Option<String>,
    /// Latest observed reasoning effort.
    pub reasoning_effort: Option<ReasoningEffort>,
    /// Creation timestamp when known.
    pub created_at: Option<DateTime<Utc>>,
    /// Last update timestamp for this metadata observation.
    pub updated_at: Option<DateTime<Utc>>,
    /// Session source.
    pub source: Option<SessionSource>,
    /// Optional analytics source classification.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        with = "optional_option"
    )]
    pub thread_source: ClearableField<ThreadSource>,
    /// Optional agent nickname.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        with = "optional_option"
    )]
    pub agent_nickname: ClearableField<String>,
    /// Optional agent role.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        with = "optional_option"
    )]
    pub agent_role: ClearableField<String>,
    /// Optional canonical agent path.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        with = "optional_option"
    )]
    pub agent_path: ClearableField<String>,
    /// Working directory.
    pub cwd: Option<PathBuf>,
    /// CLI version that created the thread.
    pub cli_version: Option<String>,
    /// Approval mode.
    pub approval_mode: Option<AskForApproval>,
    /// Canonical runtime permissions.
    pub permission_profile: Option<PermissionProfile>,
    /// Last observed token usage.
    pub token_usage: Option<TokenUsage>,
    /// First user message observed for this thread.
    pub first_user_message: Option<String>,
    /// Git metadata patch.
    pub git_info: Option<GitInfoPatch>,
    /// Thread memory behavior.
    pub memory_mode: Option<MemoryMode>,
}

impl ThreadMetadataPatch {
    /// Merges another patch into this one using field-presence semantics.
    ///
    /// Omitted fields in `next` leave the current patch unchanged. Present fields replace the
    /// current value, including clear requests like `Some(None)`. Nested patches use the same
    /// semantics.
    pub fn merge(&mut self, next: Self) {
        if next.name.is_some() {
            self.name = next.name;
        }
        if next.rollout_path.is_some() {
            self.rollout_path = next.rollout_path;
        }
        if next.preview.is_some() {
            self.preview = next.preview;
        }
        if next.title.is_some() {
            self.title = next.title;
        }
        if next.model_provider.is_some() {
            self.model_provider = next.model_provider;
        }
        if next.model.is_some() {
            self.model = next.model;
        }
        if next.reasoning_effort.is_some() {
            self.reasoning_effort = next.reasoning_effort;
        }
        if next.created_at.is_some() {
            self.created_at = next.created_at;
        }
        if next.updated_at.is_some() {
            self.updated_at = next.updated_at;
        }
        if next.source.is_some() {
            self.source = next.source;
        }
        if next.thread_source.is_some() {
            self.thread_source = next.thread_source;
        }
        if next.agent_nickname.is_some() {
            self.agent_nickname = next.agent_nickname;
        }
        if next.agent_role.is_some() {
            self.agent_role = next.agent_role;
        }
        if next.agent_path.is_some() {
            self.agent_path = next.agent_path;
        }
        if next.cwd.is_some() {
            self.cwd = next.cwd;
        }
        if next.cli_version.is_some() {
            self.cli_version = next.cli_version;
        }
        if next.approval_mode.is_some() {
            self.approval_mode = next.approval_mode;
        }
        if next.permission_profile.is_some() {
            self.permission_profile = next.permission_profile;
        }
        if next.token_usage.is_some() {
            self.token_usage = next.token_usage;
        }
        if next.first_user_message.is_some() {
            self.first_user_message = next.first_user_message;
        }
        if let Some(git_info) = next.git_info {
            self.git_info
                .get_or_insert_with(GitInfoPatch::default)
                .merge(git_info);
        }
        if next.memory_mode.is_some() {
            self.memory_mode = next.memory_mode;
        }
    }

    pub fn is_empty(&self) -> bool {
        self.name.is_none()
            && self.rollout_path.is_none()
            && self.preview.is_none()
            && self.title.is_none()
            && self.model_provider.is_none()
            && self.model.is_none()
            && self.reasoning_effort.is_none()
            && self.created_at.is_none()
            && self.updated_at.is_none()
            && self.source.is_none()
            && self.thread_source.is_none()
            && self.agent_nickname.is_none()
            && self.agent_role.is_none()
            && self.agent_path.is_none()
            && self.cwd.is_none()
            && self.cli_version.is_none()
            && self.approval_mode.is_none()
            && self.permission_profile.is_none()
            && self.token_usage.is_none()
            && self.first_user_message.is_none()
            && self.git_info.is_none()
            && self.memory_mode.is_none()
    }
}

/// Parameters for patching mutable thread metadata.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UpdateThreadMetadataParams {
    /// Thread id to update.
    pub thread_id: ThreadId,
    /// Patch to apply.
    pub patch: ThreadMetadataPatch,
    /// Whether archived threads are eligible.
    pub include_archived: bool,
}

/// Parameters for archiving or unarchiving a thread.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchiveThreadParams {
    /// Thread id to archive or unarchive.
    pub thread_id: ThreadId,
}

/// Parameters for deleting a thread.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeleteThreadParams {
    /// Thread id to delete.
    pub thread_id: ThreadId,
}

#[cfg(test)]
mod tests {
    use pretty_assertions::assert_eq;
    use serde_json::json;

    use super::*;

    #[test]
    fn thread_metadata_patch_round_trips_optional_clears() {
        let patch = ThreadMetadataPatch {
            name: Some(None),
            thread_source: Some(None),
            agent_nickname: Some(None),
            agent_role: Some(None),
            agent_path: Some(None),
            ..Default::default()
        };

        let value = serde_json::to_value(&patch).expect("serialize patch");
        assert_eq!(value["name"], json!(null));
        assert_eq!(value["thread_source"], json!(null));
        assert_eq!(value["agent_nickname"], json!(null));
        assert_eq!(value["agent_role"], json!(null));
        assert_eq!(value["agent_path"], json!(null));

        let decoded: ThreadMetadataPatch =
            serde_json::from_value(value).expect("deserialize patch");
        assert_eq!(decoded.name, Some(None));
        assert_eq!(decoded.thread_source, Some(None));
        assert_eq!(decoded.agent_nickname, Some(None));
        assert_eq!(decoded.agent_role, Some(None));
        assert_eq!(decoded.agent_path, Some(None));
    }

    #[test]
    fn git_info_patch_round_trips_optional_clears() {
        let patch = ThreadMetadataPatch {
            git_info: Some(GitInfoPatch {
                sha: None,
                branch: Some(Some("main".to_string())),
                origin_url: Some(None),
            }),
            ..Default::default()
        };

        let value = serde_json::to_value(&patch).expect("serialize patch");
        assert_eq!(
            value["git_info"],
            json!({
                "branch": "main",
                "origin_url": null,
            })
        );

        let decoded: ThreadMetadataPatch =
            serde_json::from_value(value).expect("deserialize patch");
        assert_eq!(
            decoded.git_info,
            Some(GitInfoPatch {
                sha: None,
                branch: Some(Some("main".to_string())),
                origin_url: Some(None),
            })
        );
    }

    #[test]
    fn thread_metadata_patch_accepts_missing_fields() {
        let decoded: ThreadMetadataPatch =
            serde_json::from_value(json!({})).expect("deserialize legacy patch");

        assert!(decoded.is_empty());
    }

    #[test]
    fn thread_metadata_patch_merge_uses_presence_semantics() {
        let mut current = ThreadMetadataPatch {
            name: Some(Some("old name".to_string())),
            preview: Some("old preview".to_string()),
            git_info: Some(GitInfoPatch {
                sha: Some(Some("abc123".to_string())),
                branch: Some(Some("main".to_string())),
                origin_url: None,
            }),
            ..Default::default()
        };

        current.merge(ThreadMetadataPatch {
            name: Some(None),
            preview: None,
            title: Some("new title".to_string()),
            git_info: Some(GitInfoPatch {
                sha: None,
                branch: Some(Some("feature".to_string())),
                origin_url: Some(None),
            }),
            ..Default::default()
        });

        assert_eq!(current.name, Some(None));
        assert_eq!(current.preview.as_deref(), Some("old preview"));
        assert_eq!(current.title.as_deref(), Some("new title"));
        assert_eq!(
            current.git_info,
            Some(GitInfoPatch {
                sha: Some(Some("abc123".to_string())),
                branch: Some(Some("feature".to_string())),
                origin_url: Some(None),
            })
        );
    }
}
