//! QA service — domain logic for requesting, approving, and rejecting QA reviews.
//!
//! Extracted from `http/mcp/qa_tools.rs` following the conventions in
//! `docs/architecture/service-modules.md`:
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration
//! - `io.rs` — the ONLY place that performs side effects (git, TCP, process)
//! - `lifecycle.rs` — pure QA routing decisions (spike vs. normal story)

/// Side-effectful QA I/O — git operations, port scanning, branch merging.
pub mod io;
/// Pure QA routing decisions (spike vs. normal story).
pub mod lifecycle;

pub use io::{find_free_port, merge_spike_branch_to_master};
pub use lifecycle::is_spike;

// ── Error type ────────────────────────────────────────────────────────────────

/// Typed errors returned by `service::qa` functions.
///
/// HTTP handlers map these to status codes:
/// - [`Error::NotFound`] → 404 Not Found
/// - [`Error::Validation`] → 400 Bad Request
/// - [`Error::Conflict`] → 409 Conflict (merge conflicts)
/// - [`Error::Io`] → 500 Internal Server Error
/// - [`Error::UpstreamFailure`] → 500 Internal Server Error
#[allow(dead_code)]
#[derive(Debug)]
pub enum Error {
    /// The requested story or worktree was not found.
    NotFound(String),
    /// A required argument is missing or has an invalid value.
    Validation(String),
    /// The QA approval cannot proceed due to a git conflict.
    Conflict(String),
    /// A filesystem or process I/O operation failed.
    Io(String),
    /// An upstream dependency (git, agents) returned an unexpected error.
    UpstreamFailure(String),
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotFound(msg) => write!(f, "Not found: {msg}"),
            Self::Validation(msg) => write!(f, "Validation error: {msg}"),
            Self::Conflict(msg) => write!(f, "Conflict: {msg}"),
            Self::Io(msg) => write!(f, "I/O error: {msg}"),
            Self::UpstreamFailure(msg) => write!(f, "Upstream failure: {msg}"),
        }
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

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

    #[test]
    fn error_display_not_found() {
        let e = Error::NotFound("story missing".to_string());
        assert!(e.to_string().contains("Not found"));
    }

    #[test]
    fn error_display_validation() {
        let e = Error::Validation("notes required".to_string());
        assert!(e.to_string().contains("Validation error"));
    }

    #[test]
    fn error_display_conflict() {
        let e = Error::Conflict("merge conflict".to_string());
        assert!(e.to_string().contains("Conflict"));
    }

    #[test]
    fn error_display_io() {
        let e = Error::Io("port bind failed".to_string());
        assert!(e.to_string().contains("I/O error"));
    }

    #[test]
    fn error_display_upstream_failure() {
        let e = Error::UpstreamFailure("git not found".to_string());
        assert!(e.to_string().contains("Upstream failure"));
    }

    #[test]
    fn find_free_port_returns_bindable_port() {
        // Scan from an OS-assigned ephemeral port rather than a hardcoded low
        // port. Fixed ports like 3100 are also used as scan starts elsewhere
        // in production code and tests, so a second, independent bind on a
        // fixed port can race against those under parallel test execution.
        let reservation = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
        let start = reservation.local_addr().unwrap().port();
        drop(reservation);

        let port = find_free_port(start);
        // The returned port must be bindable.
        assert!(
            std::net::TcpListener::bind(("127.0.0.1", port)).is_ok(),
            "port {port} should be bindable"
        );
    }
}
