import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from crlfpatch import patch

D = 'crates/spt-daemon/src/'

# ------------------------------------------------------------- REQ amend
patch('traceable-reqs.toml', [
('''An in-process listener (the IR-136 serving fixture) still publishes directly. An older broker answers the announce with `unknown command kind`, drained on the round-trip. Gate: impl — announce client, broker record, supervisor retire; unit — accept on exact generation, refuse others, retire on exit, in-process listener wins; int — rides REQ-WEB-SERVING-IN-BRAIN's refresh: the port is re-announced by the new generation."''',
'''An in-process listener (the IR-136 serving fixture) still publishes directly. An older broker answers the announce with `unknown command kind`, drained on the round-trip. THE ANNOUNCE WINDOW (golden-intake red, doyle ruling 2026-09-25): a daemon start returns when seed control answers, BEFORE the brain has bound, so DocsStatus distinguishes PENDING (a supervised generation exists and has not announced — the autostart window and every brain-restart gap) from UNAVAILABLE (the live generation announced that it bound nothing). The brain ALWAYS announces after its bring-up: its port, or 0 for a failed bind or an unknown hostname, which the broker records so the answer is an immediate UNAVAILABLE rather than a pending that never resolves. CALLER SCOPE: only the CLI URL-composing path (serveverb::node_and_port — serve, send attachment, docs url) waits on PENDING, re-asking every 100 ms for at most 15 s and then failing DOCS_LISTENER_PENDING; daemon-internal callers and the LiveOnly update-pointer path stay single-shot, because a brain thread or a now-signal render must never block on a port. No caller ever substitutes a configured port. A pre-W9 brain on a W9 broker (a rollback across the boundary) never announces and reads PENDING until the bound: accepted. Gate: impl — announce client (incl. the 0 announce), broker record, supervisor retire, pending status, the bounded CLI wait; unit — accept on exact generation, refuse others, retire on exit, in-process listener wins, the pending/unavailable/port matrix, and the scripted-reply wait that retries on pending then fails PENDING at the bound; int — rides REQ-WEB-SERVING-IN-BRAIN's refresh (the port is re-announced by the new generation), a URL verb issued straight after `daemon start` prints the brain's port, and a held port answers UNAVAILABLE well inside the bound."'''),
])

# ------------------------------------------------------------- docshost
patch(D + 'docshost.rs', [
('''// [impl->REQ-WEB-URL-BOUND-PORT]
pub(crate) fn bound_docs_port() -> Option<u16> {
    reported_docs_port(
        BOUND_DOCS_PORT.load(Ordering::Acquire),
        *ANNOUNCED_DOCS_PORT.lock().unwrap_or_else(|p| p.into_inner()),
        crate::brainproc::supervised_generation(),
    )
}

/// What DocsStatus reports: an in-process listener (the IR-136 serving
/// fixture) wins; otherwise the announced port answers only while the
/// generation that announced it is still the supervised one, so a replaced
/// brain's port is never reported as live. Pure.
// [impl->REQ-WEB-BOUND-PORT-GENERATION-SCOPED]
fn reported_docs_port(
    in_process: u16,
    announced: Option<(u64, u16)>,
    supervised: Option<u64>,
) -> Option<u16> {
    if in_process != 0 {
        return Some(in_process);
    }
    match announced {
        Some((generation, port)) if port != 0 && supervised == Some(generation) => Some(port),
        _ => None,
    }
}
''',
'''// [impl->REQ-WEB-URL-BOUND-PORT]
pub(crate) fn bound_docs_port() -> Option<u16> {
    docs_status().0
}

/// DocsStatus as the broker answers it: `(port, pending)`.
// [impl->REQ-WEB-BOUND-PORT-GENERATION-SCOPED]
pub(crate) fn docs_status() -> (Option<u16>, bool) {
    docs_status_from(
        BOUND_DOCS_PORT.load(Ordering::Acquire),
        *ANNOUNCED_DOCS_PORT.lock().unwrap_or_else(|p| p.into_inner()),
        crate::brainproc::supervised_generation(),
    )
}

/// What DocsStatus reports, as `(port, pending)`. An in-process listener (the
/// IR-136 serving fixture) wins. Otherwise only the SUPERVISED generation's
/// own announce answers: its port, or — when it announced 0 — none, settled.
/// A supervised generation with no announce yet is PENDING: the window
/// between seed control answering and the brain binding, and every
/// brain-restart gap (a replaced brain's record never answers for its
/// successor). No supervised brain at all is simply none. Pure.
// [impl->REQ-WEB-BOUND-PORT-GENERATION-SCOPED]
fn docs_status_from(
    in_process: u16,
    announced: Option<(u64, u16)>,
    supervised: Option<u64>,
) -> (Option<u16>, bool) {
    if in_process != 0 {
        return (Some(in_process), false);
    }
    match (announced, supervised) {
        (Some((generation, port)), Some(live)) if generation == live => ((port != 0).then_some(port), false),
        (_, Some(_)) => (None, true),
        (_, None) => (None, false),
    }
}
'''),
('''    // [unit->REQ-WEB-BOUND-PORT-GENERATION-SCOPED] what DocsStatus reports:
    // an in-process listener wins; an announced port answers only while its
    // generation is the supervised one; a 0 port and an unsupervised broker
    // (no brain spawned by this process) never answer.
    #[test]
    fn the_reported_port_is_scoped_to_the_announcing_generation() {
        assert_eq!(reported_docs_port(0, None, Some(3)), None, "nothing announced");
        assert_eq!(reported_docs_port(0, Some((3, 5474)), Some(3)), Some(5474), "live generation answers");
        assert_eq!(
            reported_docs_port(0, Some((3, 5474)), Some(4)),
            None,
            "a replaced generation's port is never reported"
        );
        assert_eq!(reported_docs_port(0, Some((3, 5474)), None), None, "no supervised brain");
        assert_eq!(reported_docs_port(0, Some((3, 0)), Some(3)), None, "port 0 is not a listener");
        assert_eq!(
            reported_docs_port(6000, Some((3, 5474)), Some(3)),
            Some(6000),
            "an in-process listener (the serving fixture) wins"
        );
    }
''',
'''    // [unit->REQ-WEB-BOUND-PORT-GENERATION-SCOPED] what DocsStatus reports,
    // as (port, pending): an in-process listener wins; only the supervised
    // generation's own announce answers; its 0 announce is a settled "none";
    // a supervised generation that has not announced (the autostart window, a
    // restart gap) is PENDING; no supervised brain is a settled none.
    #[test]
    fn the_reported_port_is_scoped_to_the_announcing_generation() {
        assert_eq!(docs_status_from(0, None, Some(3)), (None, true), "not announced yet: pending");
        assert_eq!(docs_status_from(0, Some((3, 5474)), Some(3)), (Some(5474), false), "live generation answers");
        assert_eq!(
            docs_status_from(0, Some((3, 5474)), Some(4)),
            (None, true),
            "a replaced generation's port is never reported; its successor is pending"
        );
        assert_eq!(docs_status_from(0, Some((3, 5474)), None), (None, false), "no supervised brain");
        assert_eq!(docs_status_from(0, None, None), (None, false), "nothing at all");
        assert_eq!(
            docs_status_from(0, Some((3, 0)), Some(3)),
            (None, false),
            "a 0 announce is a settled 'bound nothing', never pending"
        );
        assert_eq!(
            docs_status_from(6000, Some((3, 5474)), Some(3)),
            (Some(6000), false),
            "an in-process listener (the serving fixture) wins"
        );
    }
'''),
('''/// Record the supervised brain's announced port (the broker's announce
/// handler has already checked the generation).
''',
'''/// Record the supervised brain's announced port — 0 when it bound nothing
/// (the broker's announce handler has already checked the generation).
'''),
])

# ------------------------------------------------------------- broker
patch(D + 'broker.rs', [
('''            Ok(a) if a.port != 0 && coordinator_announce_accepted(a.generation, supervised) => {
                crate::docshost::record_announced_port(a.generation, a.port);''',
'''            // Port 0 is RECORDED: "this generation bound nothing" settles
            // DocsStatus as unavailable instead of leaving it pending.
            Ok(a) if coordinator_announce_accepted(a.generation, supervised) => {
                crate::docshost::record_announced_port(a.generation, a.port);'''),
])

# ------------------------------------------------------------- msg
patch(D + 'msg.rs', [
('''/// Fields default so a truncated frame still decodes; a defaulted generation
/// is checked against the live one and a defaulted port of 0 is refused.''',
'''/// Fields default so a truncated frame still decodes; a defaulted generation
/// is checked against the live one. Port 0 means this generation bound
/// NOTHING, and is recorded as such so DocsStatus settles as unavailable.'''),
('''    /// The actually-bound loopback docs port.
    #[serde(default)]
    pub port: u16,''',
'''    /// The actually-bound loopback docs port, or 0 when the bind failed.
    #[serde(default)]
    pub port: u16,'''),
])

# ------------------------------------------------------------- brainproc
patch(D + 'brainproc.rs', [
('''    if let Some(port) = crate::docshost::start_daemon_serving(name.clone()) {
        match brain.announce_docs_port(generation, port) {
            Ok(true) => {}
            Ok(false) => spt_proto::emit_line_err!(
                "DOCS_PORT_UNRECORDED: broker did not record port {port} for generation {generation} — DocsStatus will report the listener as unavailable"
            ),
            Err(e) => spt_proto::emit_line_err!("DOCS_PORT_ANNOUNCE_NONFATAL: {e} — continuing"),
        }
    }''',
'''    // A FAILED bring-up is announced too, as port 0: DocsStatus then settles
    // as unavailable at once, instead of reading PENDING — which a URL verb
    // waits out — for a brain that will never bind.
    let port = crate::docshost::start_daemon_serving(name.clone()).unwrap_or(0);
    match brain.announce_docs_port(generation, port) {
        Ok(true) => {}
        Ok(false) => spt_proto::emit_line_err!(
            "DOCS_PORT_UNRECORDED: broker did not record port {port} for generation {generation} — DocsStatus will read pending"
        ),
        Err(e) => spt_proto::emit_line_err!("DOCS_PORT_ANNOUNCE_NONFATAL: {e} — continuing"),
    }'''),
])

# ------------------------------------------------------------- servehost
patch(D + 'servehost.rs', [
('''    // [impl->REQ-WEB-URL-BOUND-PORT]
    DocsStatus { port: Option<u16> },''',
'''    // [impl->REQ-WEB-URL-BOUND-PORT]
    DocsStatus {
        port: Option<u16>,
        /// The supervised brain has not announced its port YET (the window
        /// between a daemon start and the brain's bind, or a brain-restart
        /// gap). Additive: an older CLI ignores it, an older broker never
        /// sends it (it owns its listener).
        // [impl->REQ-WEB-BOUND-PORT-GENERATION-SCOPED]
        #[serde(default)]
        pending: bool,
    },'''),
('''        return Ok(ServeResult::DocsStatus { port: crate::docshost::bound_docs_port() });''',
'''        let (port, pending) = crate::docshost::docs_status();
        return Ok(ServeResult::DocsStatus { port, pending });'''),
('''pub fn docs_port(name: &str) -> io::Result<u16> {
    port_from_status(call(name, ServeRequest::DocsStatus)?)
}
''',
'''pub fn docs_port(name: &str) -> io::Result<u16> {
    port_from_status(call(name, ServeRequest::DocsStatus)?)
}

/// How long a URL-composing CLI verb waits for the brain's announce.
pub const DOCS_PORT_PENDING_BOUND: std::time::Duration = std::time::Duration::from_secs(15);
/// How often it re-asks while the answer is pending.
pub const DOCS_PORT_PENDING_POLL: std::time::Duration = std::time::Duration::from_millis(100);

/// [`docs_port`] for a CLI verb that composes a URL, possibly straight after
/// it autostarted the daemon: seed control answers before the supervised
/// brain has bound, so a PENDING answer is re-asked every
/// [`DOCS_PORT_PENDING_POLL`] for at most [`DOCS_PORT_PENDING_BOUND`], then
/// fails `DOCS_LISTENER_PENDING`. ONLY the CLI URL path calls this (doyle
/// ruling): a brain thread or a now-signal render must never block on a
/// port, so every daemon-internal caller stays on [`docs_port`].
// [impl->REQ-WEB-BOUND-PORT-GENERATION-SCOPED]
pub fn docs_port_awaiting_brain(name: &str) -> io::Result<u16> {
    await_announced_port(
        || call(name, ServeRequest::DocsStatus),
        DOCS_PORT_PENDING_POLL,
        DOCS_PORT_PENDING_BOUND,
        std::thread::sleep,
    )
}

/// The wait itself, over an injected ask and sleep so a unit can script the
/// replies. A transport error returns at once (no daemon is not a pending
/// brain — the caller's fallback rules decide that); any settled answer
/// returns at once; PENDING re-asks until the bound, and the final pending
/// answer becomes `DOCS_LISTENER_PENDING`.
// [impl->REQ-WEB-BOUND-PORT-GENERATION-SCOPED]
fn await_announced_port(
    mut ask: impl FnMut() -> io::Result<ServeResult>,
    poll: std::time::Duration,
    bound: std::time::Duration,
    mut sleep: impl FnMut(std::time::Duration),
) -> io::Result<u16> {
    let mut waited = std::time::Duration::ZERO;
    loop {
        let reply = ask()?;
        let pending = matches!(reply, ServeResult::DocsStatus { pending: true, .. });
        if !pending || waited >= bound {
            return port_from_status(reply);
        }
        sleep(poll);
        waited += poll;
    }
}
'''),
('''        ServeResult::DocsStatus { port: Some(port) } if port != 0 => Ok(port),
        ServeResult::DocsStatus { .. } => Err(io::Error::other(''',
'''        ServeResult::DocsStatus { port: Some(port), .. } if port != 0 => Ok(port),
        // [impl->REQ-WEB-BOUND-PORT-GENERATION-SCOPED]
        ServeResult::DocsStatus { pending: true, .. } => Err(io::Error::other(
            "DOCS_LISTENER_PENDING: the daemon's brain has not announced its docs port yet",
        )),
        ServeResult::DocsStatus { .. } => Err(io::Error::other('''),
('''#[cfg(test)]
mod tests {
    use super::*;
''',
'''#[cfg(test)]
mod tests {
    use super::*;

    fn status(port: Option<u16>, pending: bool) -> ServeResult {
        ServeResult::DocsStatus { port, pending }
    }

    // [unit->REQ-WEB-BOUND-PORT-GENERATION-SCOPED] the CLI wait: pending is
    // re-asked at the poll and resolves to the announced port; a settled
    // UNAVAILABLE returns on the first ask; a transport error returns at once;
    // a pending that outlives the bound fails DOCS_LISTENER_PENDING, never a
    // guessed port.
    #[test]
    fn the_cli_wait_retries_pending_then_names_it_at_the_bound() {
        use std::cell::Cell;
        use std::time::Duration;
        let poll = Duration::from_millis(100);
        let bound = Duration::from_millis(500);

        let mut script = vec![status(None, true), status(None, true), status(Some(5474), false)].into_iter();
        let slept = Cell::new(0);
        let got = await_announced_port(|| Ok(script.next().unwrap()), poll, bound, |_| slept.set(slept.get() + 1));
        assert_eq!(got.unwrap(), 5474, "pending resolves to the announced port");
        assert_eq!(slept.get(), 2, "one poll per pending answer");

        let asks = Cell::new(0);
        let settled = await_announced_port(
            || {
                asks.set(asks.get() + 1);
                Ok(status(None, false))
            },
            poll,
            bound,
            |_| panic!("a settled answer never sleeps"),
        );
        assert!(settled.unwrap_err().to_string().contains("DOCS_LISTENER_UNAVAILABLE"));
        assert_eq!(asks.get(), 1);

        let refused = await_announced_port(
            || Err(io::Error::from(io::ErrorKind::NotFound)),
            poll,
            bound,
            |_| panic!("no daemon is not a pending brain"),
        );
        assert_eq!(refused.unwrap_err().kind(), io::ErrorKind::NotFound, "the caller's fallback rules still see NotFound");

        let asks = Cell::new(0);
        let forever = await_announced_port(
            || {
                asks.set(asks.get() + 1);
                Ok(status(None, true))
            },
            poll,
            bound,
            |_| {},
        );
        let error = forever.unwrap_err();
        assert!(error.to_string().contains("DOCS_LISTENER_PENDING"), "{error}");
        assert_eq!(error.kind(), io::ErrorKind::Other, "never the NotFound/refused kinds a fallback would guess on");
        assert_eq!(asks.get(), 6, "asks at 0,100..500 ms then stops at the bound");
    }
'''),
])

# ------------------------------------------------------------- serveverb
patch('crates/spt/src/serveverb.rs', [
('''    let port = match spt_daemon::servehost::docs_port(&spt_daemon::endpoint::seed_socket_name()) {
        Ok(port) => port,
        Err(error) if may_fall_back(error.kind()) => spt_daemon::docshost::resolve_docs_port(''',
'''    // The ONE caller that waits out a pending brain announce (releases#305):
    // a URL verb may run straight after it autostarted the daemon.
    // [impl->REQ-WEB-BOUND-PORT-GENERATION-SCOPED]
    let port = match spt_daemon::servehost::docs_port_awaiting_brain(&spt_daemon::endpoint::seed_socket_name()) {
        Ok(port) => port,
        Err(error) if may_fall_back(error.kind()) => spt_daemon::docshost::resolve_docs_port('''),
])
