ans-client 0.1.3

ANS API client for agent registration and management
Documentation
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
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
//! API request and response models for the ANS Registry.
//!
//! These types map to the `OpenAPI` specification for the ANS API.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
use uuid::Uuid;

/// Communication protocol used by agents.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Protocol {
    /// Agent-to-Agent protocol.
    #[serde(rename = "A2A")]
    A2A,
    /// Model Context Protocol.
    #[serde(rename = "MCP")]
    Mcp,
    /// HTTP-based API.
    #[serde(rename = "HTTP-API", alias = "HTTP_API")]
    HttpApi,
}

impl fmt::Display for Protocol {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::A2A => write!(f, "A2A"),
            Self::Mcp => write!(f, "MCP"),
            Self::HttpApi => write!(f, "HTTP-API"),
        }
    }
}

/// Transport mechanism for agent communication.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Transport {
    /// Streamable HTTP transport.
    #[serde(rename = "STREAMABLE-HTTP", alias = "STREAMABLE_HTTP")]
    StreamableHttp,
    /// Server-Sent Events.
    #[serde(rename = "SSE")]
    Sse,
    /// JSON-RPC transport.
    #[serde(rename = "JSON-RPC", alias = "JSON_RPC")]
    JsonRpc,
    /// gRPC transport.
    #[serde(rename = "GRPC")]
    Grpc,
    /// REST transport.
    #[serde(rename = "REST")]
    Rest,
    /// Generic HTTP transport.
    #[serde(rename = "HTTP")]
    Http,
}

/// A function/capability provided by an agent endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AgentFunction {
    /// Unique identifier for the function.
    pub id: String,
    /// Human-readable name.
    pub name: String,
    /// Tags for categorization.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
}

impl AgentFunction {
    /// Create a new agent function.
    pub fn new(id: impl Into<String>, name: impl Into<String>, tags: Vec<String>) -> Self {
        Self {
            id: id.into(),
            name: name.into(),
            tags,
        }
    }
}

/// An agent endpoint configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AgentEndpoint {
    /// URL where the agent accepts requests.
    pub agent_url: String,
    /// URL for agent metadata.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub meta_data_url: Option<String>,
    /// URL for agent documentation.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub documentation_url: Option<String>,
    /// Communication protocol.
    pub protocol: Protocol,
    /// Supported transport mechanisms.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub transports: Vec<Transport>,
    /// Functions provided by this endpoint.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub functions: Vec<AgentFunction>,
}

impl AgentEndpoint {
    /// Create a new endpoint with required fields.
    pub fn new(agent_url: impl Into<String>, protocol: Protocol) -> Self {
        Self {
            agent_url: agent_url.into(),
            meta_data_url: None,
            documentation_url: None,
            protocol,
            transports: Vec::new(),
            functions: Vec::new(),
        }
    }

    /// Set the transport mechanisms.
    pub fn with_transports(mut self, transports: Vec<Transport>) -> Self {
        self.transports = transports;
        self
    }

    /// Set the functions.
    pub fn with_functions(mut self, functions: Vec<AgentFunction>) -> Self {
        self.functions = functions;
        self
    }
}

/// Request to register a new agent.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AgentRegistrationRequest {
    /// Human-readable agent name.
    pub agent_display_name: String,
    /// FQDN where the agent is hosted.
    pub agent_host: String,
    /// Semantic version (e.g., "1.0.0").
    pub version: String,
    /// Optional description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub agent_description: Option<String>,
    /// CSR for identity certificate (required).
    #[serde(rename = "identityCsrPEM")]
    pub identity_csr_pem: String,
    /// CSR for server certificate (mutually exclusive with `server_certificate_pem`).
    #[serde(rename = "serverCsrPEM", skip_serializing_if = "Option::is_none")]
    pub server_csr_pem: Option<String>,
    /// BYOC server certificate (mutually exclusive with `server_csr_pem`).
    #[serde(
        rename = "serverCertificatePEM",
        skip_serializing_if = "Option::is_none"
    )]
    pub server_certificate_pem: Option<String>,
    /// Certificate chain for BYOC server certificate.
    #[serde(
        rename = "serverCertificateChainPEM",
        skip_serializing_if = "Option::is_none"
    )]
    pub server_certificate_chain_pem: Option<String>,
    /// Agent endpoints.
    pub endpoints: Vec<AgentEndpoint>,
}

impl AgentRegistrationRequest {
    /// Create a new registration request with required fields.
    pub fn new(
        agent_display_name: impl Into<String>,
        agent_host: impl Into<String>,
        version: impl Into<String>,
        identity_csr_pem: impl Into<String>,
        endpoints: Vec<AgentEndpoint>,
    ) -> Self {
        Self {
            agent_display_name: agent_display_name.into(),
            agent_host: agent_host.into(),
            version: version.into(),
            agent_description: None,
            identity_csr_pem: identity_csr_pem.into(),
            server_csr_pem: None,
            server_certificate_pem: None,
            server_certificate_chain_pem: None,
            endpoints,
        }
    }

    /// Set the agent description.
    pub fn with_description(mut self, description: impl Into<String>) -> Self {
        self.agent_description = Some(description.into());
        self
    }

    /// Set the server CSR PEM.
    pub fn with_server_csr_pem(mut self, csr: impl Into<String>) -> Self {
        self.server_csr_pem = Some(csr.into());
        self
    }

    /// Set the server certificate PEM (BYOC).
    pub fn with_server_certificate_pem(mut self, cert: impl Into<String>) -> Self {
        self.server_certificate_pem = Some(cert.into());
        self
    }

    /// Set the server certificate chain PEM (BYOC).
    pub fn with_server_certificate_chain_pem(mut self, chain: impl Into<String>) -> Self {
        self.server_certificate_chain_pem = Some(chain.into());
        self
    }
}

/// Registration status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum RegistrationStatus {
    /// Waiting for domain validation.
    PendingValidation,
    /// Waiting for certificates.
    PendingCerts,
    /// Waiting for DNS configuration.
    PendingDns,
}

/// Agent lifecycle status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum AgentLifecycleStatus {
    /// Waiting for validation.
    PendingValidation,
    /// Waiting for DNS.
    PendingDns,
    /// Agent is active.
    Active,
    /// Registration failed.
    Failed,
    /// Registration expired.
    Expired,
    /// Agent was revoked.
    Revoked,
}

/// HATEOAS link.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Link {
    /// Link relation type.
    pub rel: String,
    /// Link URL.
    pub href: String,
}

/// DNS record to be configured.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct DnsRecord {
    /// Full DNS record name.
    pub name: String,
    /// Record type (HTTPS, TLSA, TXT).
    #[serde(rename = "type")]
    pub record_type: String,
    /// Record value.
    pub value: String,
    /// Purpose of this record.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub purpose: Option<String>,
    /// TTL in seconds.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ttl: Option<i32>,
    /// Priority for HTTPS records.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub priority: Option<i32>,
    /// Whether this record is required.
    #[serde(default = "default_true")]
    pub required: bool,
}

fn default_true() -> bool {
    true
}

/// ACME challenge type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ChallengeType {
    /// DNS-01 challenge.
    #[serde(rename = "DNS_01")]
    Dns01,
    /// HTTP-01 challenge.
    #[serde(rename = "HTTP_01")]
    Http01,
}

/// DNS record details for ACME challenge.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct DnsRecordDetails {
    /// Record name.
    pub name: String,
    /// Record type.
    #[serde(rename = "type")]
    pub record_type: String,
    /// Record value.
    pub value: String,
}

/// ACME challenge information.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ChallengeInfo {
    /// Challenge type.
    #[serde(rename = "type")]
    pub challenge_type: ChallengeType,
    /// Challenge token.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub token: Option<String>,
    /// Key authorization string.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub key_authorization: Option<String>,
    /// HTTP path for HTTP-01 challenge.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub http_path: Option<String>,
    /// DNS record for DNS-01 challenge.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dns_record: Option<DnsRecordDetails>,
    /// Challenge expiration.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expires_at: Option<DateTime<Utc>>,
}

/// Action to take in next step.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum NextStepAction {
    /// Configure DNS records.
    ConfigureDns,
    /// Configure HTTP challenge.
    ConfigureHttp,
    /// Verify DNS records.
    VerifyDns,
    /// Validate domain ownership.
    ValidateDomain,
    /// Wait for processing.
    Wait,
}

/// A required action to continue registration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct NextStep {
    /// Action to take.
    pub action: NextStepAction,
    /// Description of the step.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// API endpoint for the action.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub endpoint: Option<String>,
    /// Estimated time in minutes.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub estimated_time_minutes: Option<i32>,
}

/// Response for pending registration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct RegistrationPending {
    /// Current registration status.
    pub status: RegistrationStatus,
    /// ANS name being registered.
    pub ans_name: String,
    /// Agent ID (when available).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub agent_id: Option<String>,
    /// Required actions.
    pub next_steps: Vec<NextStep>,
    /// ACME challenges.
    #[serde(default)]
    pub challenges: Vec<ChallengeInfo>,
    /// DNS records to configure.
    #[serde(default)]
    pub dns_records: Vec<DnsRecord>,
    /// Registration expiration.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expires_at: Option<DateTime<Utc>>,
    /// HATEOAS links.
    #[serde(default)]
    pub links: Vec<Link>,
}

impl RegistrationPending {
    /// Gets the agent ID, either from the field or by parsing the self link.
    ///
    /// The API may not include `agent_id` in the response body, but it's
    /// available in the `self` link (e.g., `/v1/agents/{agent_id}`).
    pub fn get_agent_id(&self) -> Option<String> {
        // First try the direct field
        if let Some(ref id) = self.agent_id {
            return Some(id.clone());
        }

        // Fall back to parsing from self link
        self.links
            .iter()
            .find(|link| link.rel == "self")
            .and_then(|link| {
                // Parse agent ID from href like "/v1/agents/{agent_id}" or full URL
                link.href
                    .trim_end_matches('/')
                    .rsplit('/')
                    .next()
                    .filter(|s| !s.is_empty() && *s != "agents")
                    .map(String::from)
            })
    }
}

/// Registration phase.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum RegistrationPhase {
    /// Initial setup.
    Initialization,
    /// Validating domain ownership.
    DomainValidation,
    /// Issuing certificates.
    CertificateIssuance,
    /// Provisioning DNS.
    DnsProvisioning,
    /// Registration complete.
    Completed,
}

/// Agent status information.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AgentStatus {
    /// Lifecycle status.
    pub status: AgentLifecycleStatus,
    /// Current phase.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub phase: Option<RegistrationPhase>,
    /// Completed steps.
    #[serde(default)]
    pub completed_steps: Vec<String>,
    /// Pending steps.
    #[serde(default)]
    pub pending_steps: Vec<String>,
    /// When created.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub created_at: Option<DateTime<Utc>>,
    /// Last updated.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub updated_at: Option<DateTime<Utc>>,
    /// Registration expiration.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expires_at: Option<DateTime<Utc>>,
}

/// Detailed agent information.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AgentDetails {
    /// Unique agent identifier.
    pub agent_id: String,
    /// Display name.
    pub agent_display_name: String,
    /// Hosting domain.
    pub agent_host: String,
    /// Description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub agent_description: Option<String>,
    /// Full ANS name.
    pub ans_name: String,
    /// Version.
    pub version: String,
    /// Lifecycle status.
    pub agent_status: AgentLifecycleStatus,
    /// Endpoints.
    pub endpoints: Vec<AgentEndpoint>,
    /// Registration timestamp.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub registration_timestamp: Option<DateTime<Utc>>,
    /// Last renewal timestamp.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_renewal_timestamp: Option<DateTime<Utc>>,
    /// Pending registration details.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub registration_pending: Option<RegistrationPending>,
    /// HATEOAS links.
    #[serde(default)]
    pub links: Vec<Link>,
}

/// Search criteria.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct SearchCriteria {
    /// Filter by protocol.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub protocol: Option<Protocol>,
    /// Filter by display name.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub agent_display_name: Option<String>,
    /// Filter by version.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    /// Filter by host.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub agent_host: Option<String>,
}

/// Agent search result.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AgentSearchResult {
    /// ANS name.
    pub ans_name: String,
    /// Agent ID.
    pub agent_id: String,
    /// Display name.
    pub agent_display_name: String,
    /// Description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub agent_description: Option<String>,
    /// Version.
    pub version: String,
    /// Hosting domain.
    pub agent_host: String,
    /// TTL in seconds.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ttl: Option<i32>,
    /// Registration timestamp.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub registration_timestamp: Option<DateTime<Utc>>,
    /// Endpoints.
    pub endpoints: Vec<AgentEndpoint>,
    /// HATEOAS links.
    #[serde(default)]
    pub links: Vec<Link>,
}

/// Search results response.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AgentSearchResponse {
    /// Matching agents.
    pub agents: Vec<AgentSearchResult>,
    /// Total matching count.
    pub total_count: i32,
    /// Count returned in this response.
    pub returned_count: i32,
    /// Pagination limit.
    pub limit: i32,
    /// Pagination offset.
    pub offset: i32,
    /// Whether more results are available.
    pub has_more: bool,
    /// Search criteria used.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub search_criteria: Option<SearchCriteria>,
}

/// Revocation reason (RFC 5280).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum RevocationReason {
    /// Private key compromised.
    KeyCompromise,
    /// Agent decommissioned.
    CessationOfOperation,
    /// Affiliation changed.
    AffiliationChanged,
    /// Superseded by new certificate.
    Superseded,
    /// Temporarily on hold.
    CertificateHold,
    /// Privileges withdrawn.
    PrivilegeWithdrawn,
    /// AA compromised.
    AaCompromise,
}

/// Request to revoke an agent.
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct AgentRevocationRequest {
    /// Reason for revocation.
    pub reason: RevocationReason,
    /// Additional comments.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub comments: Option<String>,
}

/// Revocation response.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AgentRevocationResponse {
    /// Agent ID.
    pub agent_id: Uuid,
    /// ANS name.
    pub ans_name: String,
    /// Status (will be REVOKED).
    pub status: AgentLifecycleStatus,
    /// When revocation occurred.
    pub revoked_at: DateTime<Utc>,
    /// Revocation reason.
    pub reason: RevocationReason,
    /// HATEOAS links.
    pub links: Vec<Link>,
}

/// Certificate information.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct CertificateResponse {
    /// CSR ID that generated this certificate.
    pub csr_id: Uuid,
    /// Certificate subject DN.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub certificate_subject: Option<String>,
    /// Certificate issuer DN.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub certificate_issuer: Option<String>,
    /// Serial number.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub certificate_serial_number: Option<String>,
    /// Validity start.
    pub certificate_valid_from: DateTime<Utc>,
    /// Validity end.
    pub certificate_valid_to: DateTime<Utc>,
    /// PEM-encoded certificate.
    #[serde(rename = "certificatePEM")]
    pub certificate_pem: String,
    /// PEM-encoded certificate chain.
    #[serde(rename = "chainPEM", skip_serializing_if = "Option::is_none")]
    pub chain_pem: Option<String>,
    /// Public key algorithm.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub certificate_public_key_algorithm: Option<String>,
    /// Signature algorithm.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub certificate_signature_algorithm: Option<String>,
}

/// CSR submission request.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct CsrSubmissionRequest {
    /// PEM-encoded CSR.
    #[serde(rename = "csrPEM")]
    pub csr_pem: String,
}

/// CSR submission response.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct CsrSubmissionResponse {
    /// Assigned CSR ID.
    pub csr_id: Uuid,
    /// Optional message.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}

/// CSR type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum CsrType {
    /// Server certificate CSR.
    Server,
    /// Identity certificate CSR.
    Identity,
}

/// CSR status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum CsrStatus {
    /// Pending processing.
    Pending,
    /// Signed and ready.
    Signed,
    /// Rejected.
    Rejected,
}

/// CSR status response.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct CsrStatusResponse {
    /// CSR ID.
    pub csr_id: Uuid,
    /// CSR type.
    #[serde(rename = "type")]
    pub csr_type: CsrType,
    /// Current status.
    pub status: CsrStatus,
    /// Submission time.
    pub submitted_at: DateTime<Utc>,
    /// Last update time.
    pub updated_at: DateTime<Utc>,
    /// Rejection reason (when rejected).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub failure_reason: Option<String>,
}

/// Resolution request.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AgentResolutionRequest {
    /// Agent host domain.
    pub agent_host: String,
    /// Version pattern (e.g., "*", "^1.0.0").
    pub version: String,
}

/// Resolution response.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AgentResolutionResponse {
    /// Resolved ANS name.
    pub ans_name: String,
    /// HATEOAS links.
    pub links: Vec<Link>,
}

// =========================================================================
// Event Types
// =========================================================================

/// Event type for agent lifecycle events.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum EventType {
    /// Agent was registered.
    AgentRegistered,
    /// Agent registration was renewed.
    AgentRenewed,
    /// Agent was revoked.
    AgentRevoked,
    /// Agent version was updated.
    AgentVersionUpdated,
}

impl std::fmt::Display for EventType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::AgentRegistered => write!(f, "AGENT_REGISTERED"),
            Self::AgentRenewed => write!(f, "AGENT_RENEWED"),
            Self::AgentRevoked => write!(f, "AGENT_REVOKED"),
            Self::AgentVersionUpdated => write!(f, "AGENT_VERSION_UPDATED"),
        }
    }
}

/// An individual agent event.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct EventItem {
    /// Log entry ID (used for pagination continuation).
    pub log_id: String,
    /// Type of event.
    pub event_type: EventType,
    /// When the event occurred.
    pub created_at: DateTime<Utc>,
    /// When the agent expires (if applicable).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expires_at: Option<DateTime<Utc>>,
    /// Agent ID.
    pub agent_id: Uuid,
    /// ANS name (e.g., `ans://v1.0.0.agent.example.com`).
    pub ans_name: String,
    /// Agent host domain.
    pub agent_host: String,
    /// Human-readable agent name.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub agent_display_name: Option<String>,
    /// Agent description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub agent_description: Option<String>,
    /// Agent version.
    pub version: String,
    /// Provider ID (for AHP filtering).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub provider_id: Option<String>,
    /// Agent endpoints at time of event.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub endpoints: Vec<AgentEndpoint>,
}

/// Paginated events response.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct EventPageResponse {
    /// List of events in this page.
    pub items: Vec<EventItem>,
    /// Last log ID for pagination (pass as `last_log_id` to get next page).
    /// None if this is the last page.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_log_id: Option<String>,
}

#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[cfg(test)]
mod tests {
    use super::*;

    fn make_pending_with_links(
        agent_id: Option<&str>,
        links: Vec<(&str, &str)>,
    ) -> RegistrationPending {
        RegistrationPending {
            status: RegistrationStatus::PendingValidation,
            ans_name: "ans://v1.0.0.agent.example.com".to_string(),
            agent_id: agent_id.map(String::from),
            next_steps: vec![],
            challenges: vec![],
            dns_records: vec![],
            expires_at: None,
            links: links
                .into_iter()
                .map(|(rel, href)| Link {
                    rel: rel.to_string(),
                    href: href.to_string(),
                })
                .collect(),
        }
    }

    #[test]
    fn test_get_agent_id_from_field() {
        let pending = make_pending_with_links(Some("direct-id"), vec![]);
        assert_eq!(pending.get_agent_id(), Some("direct-id".to_string()));
    }

    #[test]
    fn test_get_agent_id_from_self_link_path() {
        let pending = make_pending_with_links(None, vec![("self", "/v1/agents/uuid-from-link")]);
        assert_eq!(pending.get_agent_id(), Some("uuid-from-link".to_string()));
    }

    #[test]
    fn test_get_agent_id_from_self_link_full_url() {
        let pending = make_pending_with_links(
            None,
            vec![("self", "https://api.example.com/v1/agents/uuid-full-url")],
        );
        assert_eq!(pending.get_agent_id(), Some("uuid-full-url".to_string()));
    }

    #[test]
    fn test_get_agent_id_from_self_link_trailing_slash() {
        let pending = make_pending_with_links(None, vec![("self", "/v1/agents/uuid-trailing/")]);
        assert_eq!(pending.get_agent_id(), Some("uuid-trailing".to_string()));
    }

    #[test]
    fn test_get_agent_id_prefers_field_over_link() {
        let pending =
            make_pending_with_links(Some("field-id"), vec![("self", "/v1/agents/link-id")]);
        assert_eq!(pending.get_agent_id(), Some("field-id".to_string()));
    }

    #[test]
    fn test_get_agent_id_no_self_link() {
        let pending = make_pending_with_links(None, vec![("other", "/v1/something/else")]);
        assert_eq!(pending.get_agent_id(), None);
    }

    #[test]
    fn test_get_agent_id_empty_links() {
        let pending = make_pending_with_links(None, vec![]);
        assert_eq!(pending.get_agent_id(), None);
    }
}