travelagent 1.11.1

Agent-first TUI code review tool
//! Mode-specific state for `App`: local VCS review vs remote PR/MR review.
//!
//! Phase H2 lifts the 20 remote-only fields off `App` into
//! [`RemoteSessionState`] so local builds can't accidentally read or mutate
//! state that only makes sense in remote mode. `LocalState` is intentionally
//! empty today but present so the sum type is symmetric and future
//! local-specific state has a home.

use std::sync::Arc;

use travelagent_core::forge::{ForgeBackend, PrId, PrMetadata, RemoteComment, ReviewThread};
use travelagent_core::vcs::CommitInfo;

use super::RemotePanel;

/// Local-review-mode state. Empty today; reserved for future local-only
/// fields so the sum type stays symmetric and `matches!(mode, Local(_))`
/// keeps compiling as new state moves in.
#[derive(Debug, Default)]
pub struct LocalState {}

/// Remote PR/MR review state. Constructed with a forge backend (or `None`
/// for demo mode) and a concrete [`PrId`]; all other fields are populated
/// post-construction by the remote pipeline (or the demo-mode factory).
///
/// `forge` is `Option<_>` so demo mode can build an `App` in remote mode
/// without wiring a real forge — `forge_required` short-circuits mutating
/// actions when `forge.is_none()`.
pub struct RemoteSessionState {
    /// `Arc<dyn ForgeBackend>` (not `Box`) so the forge handle can be
    /// cloned and shipped into background tokio tasks — see
    /// `App::approve_pending_agent_action` which spawns the forge call
    /// off-thread so the TUI stays responsive during the HTTP round
    /// trip. Read-only from the TUI thread's perspective.
    pub forge: Option<Arc<dyn ForgeBackend>>,
    pub pr_id: PrId,
    /// Custom forge host for self-hosted instances (`None` = default
    /// `github.com` / `gitlab.com`).
    pub forge_host: Option<String>,
    pub pr_metadata: Option<PrMetadata>,
    pub pr_commits: Vec<CommitInfo>,
    pub remote_comments: Vec<RemoteComment>,
    pub review_threads: Vec<ReviewThread>,
    /// Active panel in remote PR review mode.
    pub remote_panel: RemotePanel,
    /// Conversation panel scroll offset.
    pub conversation_scroll: usize,
    /// Cursor over top-level threads in the Conversation panel.
    pub conversation_cursor: usize,
    /// When `Some`, a submitted comment becomes a reply to this thread id.
    pub replying_to_thread: Option<String>,
    /// Description panel scroll offset.
    pub description_scroll: usize,
    /// Commits list cursor.
    pub pr_commits_cursor: usize,
    /// Review verdict selection (0=Comment, 1=Approve, 2=Request Changes).
    pub review_verdict_cursor: usize,
    /// Review body text (for the review summary).
    pub review_body: String,
    /// Whether we're in the body-editing sub-mode of `ReviewSubmit`.
    pub review_body_editing: bool,
    /// Timestamp of the last successful remote refresh (for the status bar
    /// "last: {relative}" indicator). Stamped on remote app creation and
    /// on `refresh_remote`.
    pub last_refreshed_at: Option<chrono::DateTime<chrono::Utc>>,
    /// Remaining API rate limit (parsed from forge response headers).
    /// TODO: wire once `ForgeBackend` surfaces rate-limit headers.
    pub rate_limit_remaining: Option<u32>,
    /// Thread id the reaction picker will target when a reaction is
    /// selected.
    pub reaction_picker_target_thread: Option<String>,
}

impl RemoteSessionState {
    /// Construct a `RemoteSessionState` with a forge + `PrId` already
    /// wired. All other fields start empty; callers (remote pipeline,
    /// demo mode) populate them post-construction. `forge = None` is
    /// valid for demo/tests; mutating actions short-circuit via
    /// `App::forge_required`.
    pub fn new(forge: Option<Arc<dyn ForgeBackend>>, pr_id: PrId) -> Self {
        Self {
            forge,
            pr_id,
            forge_host: None,
            pr_metadata: None,
            pr_commits: Vec::new(),
            remote_comments: Vec::new(),
            review_threads: Vec::new(),
            remote_panel: RemotePanel::Files,
            conversation_scroll: 0,
            conversation_cursor: 0,
            replying_to_thread: None,
            description_scroll: 0,
            pr_commits_cursor: 0,
            review_verdict_cursor: 0,
            review_body: String::new(),
            review_body_editing: false,
            last_refreshed_at: Some(chrono::Utc::now()),
            rate_limit_remaining: None,
            reaction_picker_target_thread: None,
        }
    }
}

/// Mode discriminator for `App`. `Local` holds local-review-mode state,
/// `Remote` holds remote PR/MR state. Read via `App::remote()` /
/// `App::remote_mut()` or directly via `app.mode`.
///
/// `RemoteSessionState` is intentionally not boxed despite the size-diff
/// lint: there is exactly one `AppMode` per process (owned by `App`), and
/// the clippy size-diff warning matters for vectors/collections of this
/// enum, not for single-instance ownership. Boxing would force every read
/// site through an extra `&*` / `&mut *` with no runtime benefit.
#[allow(clippy::large_enum_variant)]
pub enum AppMode {
    Local(LocalState),
    Remote(RemoteSessionState),
}

impl AppMode {
    #[allow(dead_code)]
    pub fn is_local(&self) -> bool {
        matches!(self, Self::Local(_))
    }

    pub fn is_remote(&self) -> bool {
        matches!(self, Self::Remote(_))
    }

    pub fn remote(&self) -> Option<&RemoteSessionState> {
        if let Self::Remote(r) = self {
            Some(r)
        } else {
            None
        }
    }

    pub fn remote_mut(&mut self) -> Option<&mut RemoteSessionState> {
        if let Self::Remote(r) = self {
            Some(r)
        } else {
            None
        }
    }
}