import pathlib


def edit(path, pairs):
    p = pathlib.Path(path); s = p.read_text(encoding="utf-8")
    for old, new in pairs:
        assert s.count(old) == 1, (path, old[:70], s.count(old))
        s = s.replace(old, new)
    p.write_text(s, encoding="utf-8")


# ── F1: WebErr carries `refused`; only the access refusal maps to 403 ──────
edit("crates/spt-net/src/net/webmsg.rs", [(
'''    /// Owner → requester: the request was refused before any reply head; the
    /// message is the body the requester answers with (a `WEB` access
    /// refusal names the surface here).
    WebErr { message: String },
''', '''    /// Owner → requester: no reply head is coming. `refused` is the ACCESS
    /// refusal — the owner's `WEB` gate said no — and the requester answers
    /// 403 with `message` as the body (it names the surface). Any other
    /// failure on the owner (`refused: false`, absent on an older wire) is
    /// the owner being unable to serve, which the requester answers as 502
    /// naming the node with `message` as the reason.
    WebErr {
        message: String,
        #[serde(default)]
        refused: bool,
    },
'''), (
'''            WebRecord::WebErr {
                message: "ACCESS_DENIED: WEB".into(),
            },
        ];''', '''            WebRecord::WebErr {
                message: "ACCESS_DENIED: WEB".into(),
                refused: true,
            },
        ];'''), (
'''        let minimal: WebRecord =
            serde_json::from_str("{\\"kind\\":\\"web\\",\\"path\\":\\"/n/\\"}").unwrap();''',
'''        let older_err: WebRecord =
            serde_json::from_str("{\\"kind\\":\\"web_err\\",\\"message\\":\\"x\\"}").unwrap();
        assert_eq!(
            older_err,
            WebRecord::WebErr { message: "x".into(), refused: false },
            "an error line without the flag is NOT an access refusal"
        );
        let minimal: WebRecord =
            serde_json::from_str("{\\"kind\\":\\"web\\",\\"path\\":\\"/n/\\"}").unwrap();''')])

edit("crates/spt-daemon/src/webproxy.rs", [(
'''    let refuse = |brain: &mut Brain, message: String| -> io::Result<()> {
        let line = WebRecord::WebErr { message }.encode_line();
        brain.net_stream_send(stream_id, &line, None, true)?;
        Ok(())
    };
    let (path, query, head, range) = match serde_json::from_value::<WebRecord>(first_line.clone()) {
        Ok(WebRecord::Web { path, query, head, range }) => (path, query, head, range),
        _ => {
            let why = "WEB_BAD_REQUEST: the first record is not a web request".to_string();
            refuse(brain, why.clone())?;
            return Ok(WebServeOutcome::Failed(why));
        }
    };
    let home = spt_store::perch::spt_home();
    let docs_root = home.join("docs");
    let Some(node) = spt_store::hostlabel::os_hostname() else {
        let why = "WEB_NODE_UNKNOWN: the owning node has no hostname to serve under".to_string();
        refuse(brain, why.clone())?;
        return Ok(WebServeOutcome::Failed(why));
    };
''', '''    // Two ways to answer without a head: the owner CANNOT serve (`refused:
    // false` → the requester's 502 naming the node) and the owner WILL NOT
    // (`refused: true`, the access gate → the requester's 403 naming WEB).
    let fail = |brain: &mut Brain, message: String, refused: bool| -> io::Result<()> {
        let line = WebRecord::WebErr { message, refused }.encode_line();
        brain.net_stream_send(stream_id, &line, None, true)?;
        Ok(())
    };
    let (path, query, head, range) = match serde_json::from_value::<WebRecord>(first_line.clone()) {
        Ok(WebRecord::Web { path, query, head, range }) => (path, query, head, range),
        _ => {
            let why = "WEB_BAD_REQUEST: the first record is not a web request".to_string();
            fail(brain, why.clone(), false)?;
            return Ok(WebServeOutcome::Failed(why));
        }
    };
    let home = spt_store::perch::spt_home();
    let docs_root = home.join("docs");
    let Some(node) = spt_store::hostlabel::os_hostname() else {
        let why = "WEB_NODE_UNKNOWN: the owning node has no hostname to serve under".to_string();
        fail(brain, why.clone(), false)?;
        return Ok(WebServeOutcome::Failed(why));
    };
'''), (
'''    if access_check(&subject, origin_node, surface::WEB, InboundClass::Unsolicited).is_deny() {
        refuse(brain, deny_message())?;
        return Ok(WebServeOutcome::Refused);
    }
''', '''    if access_check(&subject, origin_node, surface::WEB, InboundClass::Unsolicited).is_deny() {
        fail(brain, deny_message(), true)?;
        return Ok(WebServeOutcome::Refused);
    }
'''), (
'''    /// The owner's refusal, carried back as 403 with the owner's own body
    /// (which names the surface).
    // [impl->REQ-WEB-CROSS-NODE-PROXY]
    pub fn refused(message: String) -> ProxyHead {
        ProxyHead::text(403, message)
    }
}
''', '''    /// The owner's refusal, carried back as 403 with the owner's own body
    /// (which names the surface).
    // [impl->REQ-WEB-CROSS-NODE-PROXY]
    pub fn refused(message: String) -> ProxyHead {
        ProxyHead::text(403, message)
    }

    /// The head for an owner's `WebErr`: ONLY the access refusal is a 403;
    /// every other reason the owner could not serve is the 502 naming the
    /// node, with the owner's message as the why. 403 ⇔ names the surface.
    // [impl->REQ-WEB-CROSS-NODE-PROXY]
    pub fn for_err(node_label: &str, message: &str, refused: bool) -> ProxyHead {
        if refused {
            ProxyHead::refused(message.to_owned())
        } else {
            ProxyHead::unavailable(node_label, &format!("the node could not serve it: {}", message.trim_end()))
        }
    }
}
'''), (
'''                        WebRecord::WebErr { message } => {
                            match head_tx.take() {
                                Some(tx) => {
                                    let _ = tx.send(ProxyHead::refused(message));
                                }
                                None => {
                                    let _ = body_tx.blocking_send(Err(io::Error::other(message)));
                                }
                            }
                            return;
                        }
''', '''                        WebRecord::WebErr { message, refused } => {
                            match head_tx.take() {
                                Some(tx) => {
                                    let _ = tx.send(ProxyHead::for_err(&target.node_label, &message, refused));
                                }
                                None => {
                                    let _ = body_tx.blocking_send(Err(io::Error::other(message)));
                                }
                            }
                            return;
                        }
'''), (
'''        let denied = ProxyHead::refused(deny_message());
        assert_eq!(denied.status, 403);
        let body = String::from_utf8(denied.inline.clone().unwrap()).unwrap();
        assert!(body.starts_with("ACCESS_DENIED: WEB: "), "{body}");
        assert!(!body.to_ascii_lowercase().contains("sender"), "no sender stamp exists to name: {body}");
    }
''', '''        let denied = ProxyHead::refused(deny_message());
        assert_eq!(denied.status, 403);
        let body = String::from_utf8(denied.inline.clone().unwrap()).unwrap();
        assert!(body.starts_with("ACCESS_DENIED: WEB: "), "{body}");
        assert!(!body.to_ascii_lowercase().contains("sender"), "no sender stamp exists to name: {body}");

        // An owner's WebErr is 403 ONLY when it is the access refusal; an
        // owner that could not serve (bad request line, no hostname) is the
        // 502 naming the node, and no surface is named in it.
        assert_eq!(ProxyHead::for_err("kitsubito", &deny_message(), true), denied);
        let could_not = ProxyHead::for_err("kitsubito", "WEB_NODE_UNKNOWN: the owning node has no hostname to serve under\\n", false);
        assert_eq!(could_not.status, 502);
        let body = String::from_utf8(could_not.inline.unwrap()).unwrap();
        assert!(body.starts_with("NODE_UNAVAILABLE: kitsubito: "), "{body}");
        assert!(body.contains("WEB_NODE_UNKNOWN"), "the owner's reason rides along: {body}");
        assert!(!body.contains("WEB:") && !body.contains("ACCESS_DENIED"), "no surface named in a 502: {body}");
    }
''')])

# ── F2: the WEB row says which side is the endpoint ────────────────────────
edit("docs-site/src/networking/access-viewing.md", [(
'''  checks the row under the fetching node's handshake-proven identity and
  answers `403` with a body naming `WEB`; the row carries no sender endpoint
  yet, so a rule on `WEB` names a node or a subnet, not an endpoint.
''', '''  checks the row under the fetching node's handshake-proven identity and
  answers `403` with a body naming `WEB`. The two sides of a `WEB` rule: its
  **origin** matches the fetching node or that node's subnet (no sender
  endpoint exists to name yet); its **subject** is the endpoint that
  registered the file, or the node itself for the index and the docs.
''')])

# ── F3: a positive control for the family=Web counter ──────────────────────
edit("crates/spt/tests/webserve_cross_node_e2e.rs", [(
'''    let url = format!("/{OWNER_LABEL}/f/report.md");

    // ══ ARM 1 — byte-equal fetch through A's listener ═══════════════════════
    // [int->REQ-WEB-CROSS-NODE-PROXY]
    let started = Instant::now();
    let (status, headers, body) = http(port_a, "GET", &url, &[]);
    eprintln!("=== fetch (A) === status={status} in {:?}\\n{headers}", started.elapsed());
    assert_eq!(status, 200, "the owner's file comes back through A:\\n{headers}\\n{}", String::from_utf8_lossy(&body));
    assert_eq!(body, expected, "byte-equal to the owner's source");
''', '''    let url = format!("/{OWNER_LABEL}/f/report.md");
    // The dispatcher's telemetry names the family it served; this count is the
    // POSITIVE CONTROL for arm 7's "no stream reached B" zero-diff: a proxied
    // fetch must raise it, or the zero would be measuring a dead emitter.
    let web_streams = |dae: &DaemonGuard| dae.stderr().matches("family=Web").count();
    let web_before_arm_1 = web_streams(&dae_b);

    // ══ ARM 1 — byte-equal fetch through A's listener ═══════════════════════
    // [int->REQ-WEB-CROSS-NODE-PROXY]
    let started = Instant::now();
    let (status, headers, body) = http(port_a, "GET", &url, &[]);
    eprintln!("=== fetch (A) === status={status} in {:?}\\n{headers}", started.elapsed());
    assert_eq!(status, 200, "the owner's file comes back through A:\\n{headers}\\n{}", String::from_utf8_lossy(&body));
    assert_eq!(body, expected, "byte-equal to the owner's source");
    let control_deadline = Instant::now() + Duration::from_secs(20);
    while web_streams(&dae_b) <= web_before_arm_1 {
        assert!(
            Instant::now() < control_deadline,
            "positive control: B's dispatcher never logged a served Web stream after a proxied fetch\\n{}",
            common::daemon_stderr_panel(&dae_b.log)
        );
        std::thread::sleep(Duration::from_millis(200));
    }
'''), (
'''    let b_log_before = dae_b.stderr();
    let (status, _, body) = http(port_a, "GET", &format!("/{OWNER_LABEL}/f/"), &[]);
''', '''    let web_before_arm_7 = web_streams(&dae_b);
    let (status, _, body) = http(port_a, "GET", &format!("/{OWNER_LABEL}/f/"), &[]);
'''), (
'''    let b_log_after = dae_b.stderr();
    assert_eq!(
        b_log_before.matches("family=Web").count(),
        b_log_after.matches("family=Web").count(),
        "no stream reached B for the locally-answered facets"
    );
''', '''    std::thread::sleep(Duration::from_millis(500));
    assert_eq!(
        web_streams(&dae_b),
        web_before_arm_7,
        "no stream reached B for the locally-answered facets (the counter is live: arm 1 raised it)"
    );
''')])
print("F1 F2 F3 applied")
