oa_gateway_owp/
session.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
//! One WebSocket session: INIT, then PUB / SUB / UNSUB until close.
//!
//! Binary frames are refused and the socket is closed. A parse error on
//! a text frame is `-ERR Illegal-Argument` and the session stays up.
//! A failed INIT closes it.

use std::collections::{HashMap, HashSet};
use std::sync::Arc;

use futures_util::{SinkExt, StreamExt};
use oa_gateway_adapter::tls::MaybeTlsStream;
use oa_gateway_adapter::AdapterError;
use oa_gateway_agra::{unwrap as unwrap_ma, wrapper_kind, xml_root_local_name};
use oa_gateway_core::{
    AdapterId, ContentType, Delivery, Engine, Envelope, RouteKey, SubId, DEFAULT_CHANNEL_CAPACITY,
};
use oa_gateway_uci::validate::{summarize, Mode as ValidateMode};
use oa_gateway_uci::Schema;
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::Instant;
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::WebSocketStream;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};

use crate::codec::{
    parse_client, type_hint_from_json, ClientOp, Identifiers, InfoPayload, InitPayload, OwpError,
    ServerOp,
};
use crate::config::OwpConfig;
use crate::convert::{toward_xml, violations_of, xml_to_oms_json};

/// Protocol version this server speaks. INIT must list it.
const OWP_VERSION: &str = "1.0";

/// One accepted WebSocket and the engine handle it publishes through.
///
/// `conn_id` is unique per accept on this adapter. Engine subscription
/// ids are `{conn_id}:{sid}` so two connections can reuse a client sid.
pub(crate) struct Session {
    pub(crate) adapter_id: AdapterId,
    pub(crate) config: OwpConfig,
    pub(crate) schema: Option<Arc<Schema>>,
    pub(crate) conn_id: u64,
    pub(crate) ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
    pub(crate) engine: Arc<Engine>,
    pub(crate) shutdown: CancellationToken,
}

enum State {
    /// First client frame must be INIT.
    AwaitingInit,
    /// Handshake succeeded. `verbose` defaults to true when INIT omits it.
    Active { verbose: bool, service_id: String },
}

/// One client SUB: the engine key and the task that forwards MSG frames.
struct LiveSub {
    engine_sub: SubId,
    forwarder: JoinHandle<()>,
}

/// Whether an unroutable publish is worth reporting yet.
enum Report {
    /// Not seen on this connection before.
    New,
    /// As [`Report::New`], and the last one this connection will report.
    Final,
    /// Already reported, or reporting has stopped.
    Skip,
}

/// Routes this connection has already been warned about.
///
/// A client that publishes into thin air, or sends a payload the schema does not
/// permit, rarely does it once, so reporting every message would bury the log.
/// Reporting also stops past [`SeenRoutes::CAP`] distinct routes, so a client
/// cycling through topics cannot turn a warning into unbounded memory or log
/// volume. One tracker per kind of warning, so a noisy route of one kind does not
/// hide the other.
#[derive(Default)]
struct SeenRoutes {
    seen: HashSet<RouteKey>,
}

/// Unroutable publishes and invalid payloads, tracked separately.
#[derive(Default)]
struct RouteWarnings {
    unroutable: SeenRoutes,
    invalid: SeenRoutes,
}

impl SeenRoutes {
    /// Distinct routes named in the log before this tracker goes quiet.
    const CAP: usize = 64;

    /// Records `route` if it is new and under the cap.
    fn report(&mut self, route: &RouteKey) -> Report {
        if self.seen.len() >= Self::CAP || self.seen.contains(route) {
            return Report::Skip;
        }
        self.seen.insert(route.clone());
        if self.seen.len() == Self::CAP {
            Report::Final
        } else {
            Report::New
        }
    }
}

/// End the WebSocket after an `-ERR` that the spec treats as fatal.
enum Fatal {
    Close,
}

/// Runs `session` until shutdown, close, or a fatal protocol error.
///
/// On the way out every live SUB is aborted and unsubscribed. This does
/// not call [`Engine::drop_adapter`]: other connections on the same
/// adapter must keep their subscriptions.
///
/// # Errors
///
/// Does not fail. Handshake already succeeded; I/O errors end the loop
/// as [`Ok`].
pub(crate) async fn run(mut session: Session) -> Result<(), AdapterError> {
    let (out_tx, mut out_rx) = mpsc::channel::<ServerOp>(DEFAULT_CHANNEL_CAPACITY);
    let mut state = State::AwaitingInit;
    let mut subs: HashMap<String, LiveSub> = HashMap::new();
    let mut warnings = RouteWarnings::default();

    let started = Instant::now();
    let mut last_frame = started;

    loop {
        // While awaiting INIT the deadline is fixed from the handshake, so a
        // peer cannot keep the slot by dribbling junk frames without ever
        // completing INIT. Once active it slides forward on every frame in
        // either direction, so a live publisher or subscriber is never closed.
        let deadline = match state {
            State::AwaitingInit => session.config.init_timeout.map(|d| started + d),
            State::Active { .. } => session.config.idle_timeout.map(|d| last_frame + d),
        };

        tokio::select! {
            () = session.shutdown.cancelled() => break,
            () = deadline_elapsed(deadline) => {
                let reason = match state {
                    State::AwaitingInit => "no INIT before the init timeout",
                    State::Active { .. } => "no frames before the idle timeout",
                };
                debug!(
                    adapter = %session.adapter_id,
                    conn_id = session.conn_id,
                    reason,
                    "closing owp session on timeout"
                );
                let _ = session
                    .ws
                    .send(Message::Close(Some(CloseFrame {
                        code: CloseCode::Policy,
                        reason: reason.into(),
                    })))
                    .await;
                break;
            }
            outgoing = out_rx.recv() => {
                let Some(op) = outgoing else { break };
                last_frame = Instant::now();
                if session.ws.send(Message::Text(op.to_string().into())).await.is_err() {
                    break;
                }
            }
            incoming = session.ws.next() => {
                let Some(frame) = incoming else { break };
                let Ok(msg) = frame else { break };
                last_frame = Instant::now();
                match msg {
                    Message::Text(text) => {
                        if handle_text(
                            &session,
                            &mut state,
                            &mut subs,
                            &mut warnings,
                            &out_tx,
                            text.as_str(),
                        )
                        .await
                        .is_err()
                        {
                            break;
                        }
                    }
                    Message::Ping(data) => {
                        let _ = session.ws.send(Message::Pong(data)).await;
                    }
                    Message::Pong(_) | Message::Frame(_) => {}
                    Message::Binary(_) => {
                        let _ = out_tx
                            .send(err_op(OwpError::IllegalOperation, Some("binary frames are not allowed")))
                            .await;
                        break;
                    }
                    Message::Close(_) => break,
                }
            }
        }
    }

    for (_, live) in subs.drain() {
        live.forwarder.abort();
        let _ = session
            .engine
            .unsubscribe(session.adapter_id.clone(), live.engine_sub)
            .await;
    }
    Ok(())
}

/// Dispatches one client text frame.
///
/// Unknown or malformed frames stay on the connection. INIT in the
/// wrong state, or a rejected INIT, returns [`Fatal::Close`].
///
/// `SUB` `group` is accepted by the codec and ignored here.
async fn handle_text(
    session: &Session,
    state: &mut State,
    subs: &mut HashMap<String, LiveSub>,
    warnings: &mut RouteWarnings,
    out_tx: &mpsc::Sender<ServerOp>,
    text: &str,
) -> Result<(), Fatal> {
    let op = match parse_client(text) {
        Ok(op) => op,
        Err(err) => {
            send(
                out_tx,
                err_op(OwpError::IllegalArgument, Some(err.to_string().as_str())),
            )
            .await;
            return Ok(());
        }
    };

    match state {
        State::AwaitingInit => {
            if let ClientOp::Init(init) = op {
                handle_init(session, state, out_tx, init).await
            } else {
                send(
                    out_tx,
                    err_op(
                        OwpError::IllegalState,
                        Some("INIT must be the first operation"),
                    ),
                )
                .await;
                Err(Fatal::Close)
            }
        }
        State::Active {
            verbose,
            service_id,
        } => match op {
            ClientOp::Init(_) => {
                send(
                    out_tx,
                    err_op(OwpError::IllegalState, Some("duplicate INIT")),
                )
                .await;
                Err(Fatal::Close)
            }
            ClientOp::Pub { topic, payload } => {
                handle_pub(
                    session, warnings, out_tx, *verbose, service_id, topic, payload,
                )
                .await
            }
            ClientOp::Sub {
                sid,
                message_name,
                topic,
                group: _,
            } => handle_sub(session, subs, out_tx, *verbose, sid, message_name, topic).await,
            ClientOp::Unsub { sid } => handle_unsub(session, subs, out_tx, *verbose, sid).await,
        },
    }
}

/// Negotiates INIT. Success sends INFO (and +OK when verbose).
async fn handle_init(
    session: &Session,
    state: &mut State,
    out_tx: &mpsc::Sender<ServerOp>,
    init: InitPayload,
) -> Result<(), Fatal> {
    match negotiate(&session.config, &init) {
        Ok(()) => {
            let verbose = init.verbose.unwrap_or(true);
            if verbose {
                send(out_tx, ServerOp::Ok).await;
            }
            send(out_tx, ServerOp::Info(info_payload(&session.config, &init))).await;
            *state = State::Active {
                verbose,
                service_id: init.service_id,
            };
            Ok(())
        }
        Err(error) => {
            send(out_tx, err_op(error, None)).await;
            Err(Fatal::Close)
        }
    }
}

/// Publishes onto the engine. A bad payload is `-ERR Invalid-Message`
/// and the session stays up.
async fn handle_pub(
    session: &Session,
    warnings: &mut RouteWarnings,
    out_tx: &mpsc::Sender<ServerOp>,
    verbose: bool,
    service_id: &str,
    topic: String,
    payload: String,
) -> Result<(), Fatal> {
    match publish_owp(session, warnings, service_id, topic, payload).await {
        Ok(()) => {
            if verbose {
                send(out_tx, ServerOp::Ok).await;
            }
        }
        Err(err) => {
            send(out_tx, err_op(OwpError::InvalidMessage, Some(&err))).await;
        }
    }
    Ok(())
}

/// Subscribes the engine and spawns a forwarder that writes MSG frames.
///
/// Duplicate `sid` or the per-connection cap is `-ERR` without closing.
/// The protocol has no resource-limit code; the cap uses Illegal-State.
async fn handle_sub(
    session: &Session,
    subs: &mut HashMap<String, LiveSub>,
    out_tx: &mpsc::Sender<ServerOp>,
    verbose: bool,
    sid: String,
    message_name: String,
    topic: String,
) -> Result<(), Fatal> {
    if subs.contains_key(&sid) {
        send(
            out_tx,
            err_op(OwpError::IllegalArgument, Some("duplicate sid")),
        )
        .await;
        return Ok(());
    }
    if subs.len() >= session.config.max_subscriptions {
        // Each subscription costs a channel, a task, and an engine index
        // entry keyed by client-supplied strings, so the count is bounded
        // per connection. The protocol has no resource-limit code, and
        // Illegal-State is the closest of the ones it defines.
        warn!(
            adapter = %session.adapter_id,
            conn_id = session.conn_id,
            limit = session.config.max_subscriptions,
            "subscription limit reached"
        );
        send(
            out_tx,
            err_op(OwpError::IllegalState, Some("subscription limit reached")),
        )
        .await;
        return Ok(());
    }
    let engine_sub = SubId::new(format!("{}:{sid}", session.conn_id));
    let (tx, mut rx) = mpsc::channel::<Delivery>(DEFAULT_CHANNEL_CAPACITY);
    if session
        .engine
        .subscribe(
            session.adapter_id.clone(),
            engine_sub.clone(),
            RouteKey::typed(topic, message_name),
            tx,
        )
        .await
        .is_err()
    {
        send(
            out_tx,
            err_op(OwpError::InternalError, Some("subscribe failed")),
        )
        .await;
        return Ok(());
    }
    let forward_tx = out_tx.clone();
    let local_sid = sid.clone();
    let xml_baseline = session.config.xml_baseline;
    let schema = session.schema.clone();
    let adapter_id = session.adapter_id.clone();
    let validate = session.config.validate;
    let forwarder = tokio::spawn(async move {
        // The client is told about every dropped delivery, since each is
        // a message it will not receive; the log is told once, since the
        // cause is the same for every payload on this route.
        let mut logged = false;
        let mut logged_invalid = false;
        let mut logged_conversion_invalid = false;
        while let Some(delivery) = rx.recv().await {
            let Ok(raw) = String::from_utf8(delivery.envelope.payload.to_vec()) else {
                warn!("dropping non-utf8 payload destined for OWP");
                continue;
            };

            // What arrived off the bus, before any conversion of ours,
            // so a violation is attributed to the producer.
            let violations = violations_of(raw.as_bytes(), schema.as_deref(), validate);
            match handle_violations(
                &violations,
                validate,
                &adapter_id,
                &local_sid,
                &forward_tx,
                &mut logged_invalid,
                ViolationWording::PRODUCER,
            )
            .await
            {
                Verdict::Drop => continue,
                Verdict::Closed => break,
                Verdict::Forward => {}
            }
            let payload = if xml_baseline {
                match xml_to_oms_json(&raw, schema.as_deref()) {
                    Ok(json) => {
                        // Conversion is a no-op when the bus payload was not XML
                        // to begin with (xml_to_oms_json returns it unchanged),
                        // in which case re-checking here would just repeat the
                        // producer check above and misattribute its violation to
                        // conversion. Only worth checking when bytes may have
                        // actually changed: conversion is best-effort, and a bug
                        // in it can produce JSON that no longer follows the
                        // schema even though the bus payload did.
                        if oa_gateway_uci::looks_like_xml(raw.as_bytes()) {
                            let violations =
                                violations_of(json.as_bytes(), schema.as_deref(), validate);
                            match handle_violations(
                                &violations,
                                validate,
                                &adapter_id,
                                &local_sid,
                                &forward_tx,
                                &mut logged_conversion_invalid,
                                ViolationWording::CONVERSION,
                            )
                            .await
                            {
                                Verdict::Drop => continue,
                                Verdict::Closed => break,
                                Verdict::Forward => {}
                            }
                        }
                        json
                    }
                    Err(err) => {
                        if !logged {
                            logged = true;
                            warn!(
                                adapter = %adapter_id,
                                sid = %local_sid,
                                error = %err,
                                "dropping a delivery that will not convert to JSON; \
                                 later failures on this subscription are not logged"
                            );
                        }
                        // Forwarding the XML instead would hand the client
                        // a format it did not subscribe for, and it has no
                        // way to tell that from an ordinary payload.
                        let details =
                            format!("delivery on {local_sid} could not be converted: {err}");
                        if forward_tx
                            .send(err_op(OwpError::InvalidMessage, Some(&details)))
                            .await
                            .is_err()
                        {
                            break;
                        }
                        continue;
                    }
                }
            } else {
                raw
            };
            if forward_tx
                .send(ServerOp::Msg {
                    sid: local_sid.clone(),
                    payload,
                })
                .await
                .is_err()
            {
                break;
            }
        }
    });
    subs.insert(
        sid,
        LiveSub {
            engine_sub,
            forwarder,
        },
    );
    if verbose {
        send(out_tx, ServerOp::Ok).await;
    }
    Ok(())
}

/// Aborts the forwarder and unsubscribes. Unknown `sid` is `-ERR`.
async fn handle_unsub(
    session: &Session,
    subs: &mut HashMap<String, LiveSub>,
    out_tx: &mpsc::Sender<ServerOp>,
    verbose: bool,
    sid: String,
) -> Result<(), Fatal> {
    if let Some(live) = subs.remove(&sid) {
        live.forwarder.abort();
        let _ = session
            .engine
            .unsubscribe(session.adapter_id.clone(), live.engine_sub)
            .await;
        if verbose {
            send(out_tx, ServerOp::Ok).await;
        }
    } else {
        send(
            out_tx,
            err_op(OwpError::IllegalArgument, Some("unknown sid")),
        )
        .await;
    }
    Ok(())
}

/// Maps one PUB onto one or two engine envelopes and publishes them.
///
/// A-GRA unwrap, XML baseline conversion, and validation all run before
/// any publish, so a wrapper and its inner are all-or-nothing. Each
/// envelope is stamped with `owp.service_id` and `owp.conn_id`.
///
/// # Errors
///
/// Returns a message for unwrap, type-hint, conversion, or reject-mode
/// validation failures. Unroutable publishes are warned, not errors.
async fn publish_owp(
    session: &Session,
    warnings: &mut RouteWarnings,
    service_id: &str,
    topic: String,
    payload: String,
) -> Result<(), String> {
    let stamp = |mut env: Envelope| {
        env.headers
            .insert("owp.service_id".into(), service_id.to_owned());
        env.headers
            .insert("owp.conn_id".into(), session.conn_id.to_string());
        env
    };

    let mut outgoing = Vec::new();
    if session.config.unwrap_ma_payloads && wrapper_kind(payload.as_bytes()).is_some() {
        let peeled = unwrap_ma(&topic, payload.as_bytes()).map_err(|e| e.to_string())?;
        outgoing.push(peeled.wrapper);
        outgoing.push(peeled.inner);
    } else {
        let hint = if oa_gateway_uci::looks_like_xml(payload.as_bytes()) {
            // The name is in the document, so no schema is needed to read it,
            // and this is how the STOMP edge names an XML payload too. The topic
            // used to stand in when the schema could not parse the payload,
            // which routed the message under a key no subscriber of that type
            // would match and reported +OK regardless.
            xml_root_local_name(&payload)
                .ok_or_else(|| "XML payload has no element to take a type from".to_string())?
        } else {
            type_hint_from_json(&payload).map_err(|e| e.to_string())?
        };
        let ct = if oa_gateway_uci::looks_like_xml(payload.as_bytes()) {
            ContentType::xml()
        } else {
            ContentType::json()
        };
        outgoing.push(
            Envelope::new(RouteKey::typed(topic, hint), payload.into_bytes()).with_content_type(ct),
        );
    }

    // Convert and check everything before publishing any of it: an A-GRA wrapper
    // and the message inside it are one publish as far as the client is
    // concerned, and half of one is worse than neither.
    let mut ready = Vec::with_capacity(outgoing.len());
    for env in outgoing {
        let env = if session.config.xml_baseline {
            let schema = session.schema.as_deref().ok_or_else(|| {
                "owp.xml_baseline is enabled but no UCI schema is loaded".to_string()
            })?;
            toward_xml(env, schema)?
        } else {
            env
        };

        let violations = violations_of(
            &env.payload,
            session.schema.as_deref(),
            session.config.validate,
        );
        if !violations.is_empty() {
            let summary = summarize(&violations);
            if session.config.validate == ValidateMode::Reject {
                return Err(format!("payload does not follow the UCI schema: {summary}"));
            }
            match warnings.invalid.report(&env.route) {
                Report::Skip => {}
                report => {
                    warn!(
                        adapter = %session.adapter_id,
                        service = %service_id,
                        route = %env.route,
                        violations = %summary,
                        "published payload does not follow the UCI schema; carrying it anyway"
                    );
                    cap_notice(report, &session.adapter_id, service_id);
                }
            }
        }

        ready.push(stamp(env));
    }

    for env in ready {
        let route = env.route.clone();
        // A publish that matches nothing is legal pub/sub, but it is far more
        // often a topic the gateway was never configured to carry — a STOMP
        // topics list without this entry, say — and the client is told "+OK"
        // either way. Say so here rather than let the message vanish.
        let outcome = session.engine.publish(env).await;
        if outcome.dropped > 0 {
            warn!(
                adapter = %session.adapter_id,
                service = %service_id,
                route = %route,
                matched = outcome.matched,
                delivered = outcome.delivered,
                dropped = outcome.dropped,
                "engine dropped deliveries on this publish"
            );
        }
        if outcome.matched == 0 {
            match warnings.unroutable.report(&route) {
                Report::Skip => {}
                report => {
                    warn!(
                        adapter = %session.adapter_id,
                        service = %service_id,
                        route = %route,
                        "nothing is subscribed to this route, so the publish went nowhere"
                    );
                    cap_notice(report, &session.adapter_id, service_id);
                }
            }
        }
    }
    Ok(())
}

/// Say when a tracker has stopped naming routes, so a quiet log is not mistaken
/// for a quiet client.
fn cap_notice(report: Report, adapter: &AdapterId, service_id: &str) {
    if matches!(report, Report::Final) {
        warn!(
            adapter = %adapter,
            service = %service_id,
            "reached the reporting limit for this connection; further routes of this \
             kind will not be named"
        );
    }
}

/// Checks INIT.versions contains [`OWP_VERSION`] and INIT.schema matches
/// when [`OwpConfig::schema`] is set.
///
/// # Errors
///
/// Returns [`OwpError::UnsupportedVersion`] or
/// [`OwpError::UnsupportedSchema`].
fn negotiate(config: &OwpConfig, init: &InitPayload) -> Result<(), OwpError> {
    if !init.versions.iter().any(|v| v == OWP_VERSION) {
        return Err(OwpError::UnsupportedVersion);
    }
    if let Some(expected) = &config.schema {
        if &init.schema != expected {
            return Err(OwpError::UnsupportedSchema);
        }
    }
    Ok(())
}

/// Builds INFO. The service UUID is v5 of the client's `service_id`.
fn info_payload(config: &OwpConfig, init: &InitPayload) -> InfoPayload {
    let service = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, init.service_id.as_bytes());
    InfoPayload {
        version: OWP_VERSION.into(),
        server_id: config.server_id.clone(),
        uuids: Identifiers {
            system: config.system_uuid.clone(),
            service: service.to_string(),
            subsystem: None,
        },
        system_label: config.system_label.clone(),
    }
}

/// What the caller of [`handle_violations`] should do with the delivery.
enum Verdict {
    /// No violations, or `Warn` mode: forward the payload as usual.
    Forward,
    /// `Reject` mode found violations: the caller should drop this delivery
    /// (`continue` its loop) after the error has already been sent.
    Drop,
    /// The client's channel is closed: the caller should stop the forwarder.
    Closed,
}

/// The wording that differs between a violation found in what the producer
/// put on the bus and one found in what `xml_baseline` conversion produced.
struct ViolationWording {
    /// Noun phrase completing "delivery on {sid} ...: {summary}".
    detail: &'static str,
    reject_log: &'static str,
    warn_log: &'static str,
}

impl ViolationWording {
    const PRODUCER: Self = Self {
        detail: "does not follow the UCI schema",
        reject_log: "dropping a delivery that does not follow the UCI schema; later ones on \
                      this subscription are not logged",
        warn_log: "delivered payload does not follow the UCI schema; forwarding it anyway, \
                    and later ones on this subscription are not logged",
    };
    const CONVERSION: Self = Self {
        detail: "converted to a payload that does not follow the UCI schema",
        reject_log: "dropping a delivery that converted to a payload not following the UCI \
                      schema; later ones on this subscription are not logged",
        warn_log: "converted payload does not follow the UCI schema; forwarding it anyway, \
                    and later ones on this subscription are not logged",
    };
}

/// Applies `validate`'s policy to `violations`: in `Reject` mode, sends a
/// `-ERR` and reports [`Verdict::Drop`]; in `Warn` mode, only logs. Either
/// way the warning is logged once per subscription, via `logged`.
async fn handle_violations(
    violations: &[oa_gateway_uci::validate::Violation],
    validate: ValidateMode,
    adapter_id: &impl std::fmt::Display,
    local_sid: &str,
    forward_tx: &mpsc::Sender<ServerOp>,
    logged: &mut bool,
    wording: ViolationWording,
) -> Verdict {
    if violations.is_empty() {
        return Verdict::Forward;
    }
    let summary = summarize(violations);
    if validate == ValidateMode::Reject {
        if !*logged {
            *logged = true;
            warn!(adapter = %adapter_id, sid = %local_sid, violations = %summary, "{}", wording.reject_log);
        }
        let details = format!("delivery on {local_sid} {}: {summary}", wording.detail);
        if forward_tx
            .send(err_op(OwpError::InvalidMessage, Some(&details)))
            .await
            .is_err()
        {
            return Verdict::Closed;
        }
        return Verdict::Drop;
    }
    if !*logged {
        *logged = true;
        warn!(adapter = %adapter_id, sid = %local_sid, violations = %summary, "{}", wording.warn_log);
    }
    Verdict::Forward
}

/// Builds a `-ERR` frame. `details` is the rest of the line when set.
fn err_op(error: OwpError, details: Option<&str>) -> ServerOp {
    ServerOp::Err {
        error,
        details: details.map(str::to_owned),
    }
}

/// Queues `op` for the writer. A full or closed channel is ignored so
/// a slow client cannot stall the read loop.
async fn send(tx: &mpsc::Sender<ServerOp>, op: ServerOp) {
    let _ = tx.send(op).await;
}

/// Resolves once `deadline` passes, or never when it is `None` (the timeout
/// disabled). Used as one arm of the session `select!`.
async fn deadline_elapsed(deadline: Option<Instant>) {
    match deadline {
        Some(at) => tokio::time::sleep_until(at).await,
        None => std::future::pending().await,
    }
}

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

    #[test]
    fn a_route_is_reported_once() {
        let mut unroutable = SeenRoutes::default();
        let route = RouteKey::typed("SystemStatus", "SystemStatus");

        assert!(matches!(unroutable.report(&route), Report::New));
        assert!(matches!(unroutable.report(&route), Report::Skip));
        assert!(matches!(unroutable.report(&route), Report::Skip));

        // A different type on the same topic is a different route.
        let other = RouteKey::typed("SystemStatus", "PositionReport");
        assert!(matches!(unroutable.report(&other), Report::New));
    }

    #[test]
    fn reporting_stops_at_the_cap() {
        let mut unroutable = SeenRoutes::default();
        for i in 0..SeenRoutes::CAP - 1 {
            let route = RouteKey::typed(format!("topic-{i}"), "Ping");
            assert!(matches!(unroutable.report(&route), Report::New));
        }

        let last = RouteKey::typed("topic-last", "Ping");
        assert!(matches!(unroutable.report(&last), Report::Final));

        // Nothing further is reported or remembered, however many routes arrive.
        for i in 0..1000 {
            let route = RouteKey::typed(format!("flood-{i}"), "Ping");
            assert!(matches!(unroutable.report(&route), Report::Skip));
        }
        assert_eq!(unroutable.seen.len(), SeenRoutes::CAP);
    }
}