syntax = "proto3";
option csharp_namespace = "ZB.MOM.WW.ScadaBridge.Communication.Grpc";
package scadabridge.centralcontrol.v1;

import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";

// The two ingest RPCs deliberately REUSE the batch/ack messages already defined
// for the site-hosted SiteStreamService rather than redeclaring them. The site
// telemetry actor builds AuditEventBatch / CachedTelemetryBatch today and hands
// them to ISiteStreamAuditClient; duplicating the shapes here would fork one
// wire contract into two that must be kept in lockstep by hand.
import "Protos/sitestream.proto";

// Central-hosted control plane (Phase 1A of the ClusterClient→gRPC migration).
//
// Direction: SITE is the client, CENTRAL is the server — the inverse of
// SiteStreamService, where central dials the site. That asymmetry is deliberate
// and mirrors the direction the Akka ClusterClient traffic used to flow: these
// seven calls are exactly the seven messages SiteCommunicationActor sends to
// /user/central-communication.
//
// Every call is gated by ControlPlaneAuthInterceptor: `authorization: Bearer <psk>`
// plus the `x-scadabridge-site` metadata header naming which site's preshared key
// central must verify against.
service CentralControlService {
  // Store-and-forward handoff of one notification for central delivery. The
  // ack is idempotent on notification_id — a duplicate submit after a lost ack
  // must not produce a second delivery.
  rpc SubmitNotification(NotificationSubmitDto) returns (NotificationSubmitAckDto);

  // Notify.Status(id) round-trip for a notification that has already left the
  // site buffer. `found = false` sends the caller back to the site-local buffer
  // to decide Forwarding vs Unknown.
  rpc QueryNotificationStatus(NotificationStatusQueryDto) returns (NotificationStatusResponseDto);

  // Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the
  // batch a site drains from its SQLite hot path is byte-identical whichever
  // transport carries it.
  rpc IngestAuditEvents(sitestream.AuditEventBatch) returns (sitestream.IngestAck);

  // Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls
  // operational upsert, written in one central transaction).
  rpc IngestCachedTelemetry(sitestream.CachedTelemetryBatch) returns (sitestream.IngestAck);

  // Node-startup self-heal: the node's local deployed inventory in, fetch
  // tokens for whatever it is missing or stale out.
  rpc ReconcileSite(ReconcileSiteRequestDto) returns (ReconcileSiteResponseDto);

  // Periodic site health report (30 s cadence). The ack makes delivery
  // observable end-to-end so the sender can restore its per-interval counters
  // when a report is lost.
  rpc ReportSiteHealth(SiteHealthReportDto) returns (SiteHealthReportAckDto);

  // Application heartbeat. Returns Empty because the message is
  // fire-and-forget: nothing on the site consumes a reply, and a failure here
  // must never surface as a fault on the heartbeat timer path.
  rpc Heartbeat(HeartbeatDto) returns (google.protobuf.Empty);
}

// ---------------------------------------------------------------------------
// Notification Outbox (#21)
// ---------------------------------------------------------------------------

// Site -> Central: submit a buffered notification for central delivery.
// Mirrors Commons NotificationSubmit.
message NotificationSubmitDto {
  string notification_id = 1;              // GUID string, the idempotency key
  string list_name = 2;
  string subject = 3;
  string body = 4;
  string source_site_id = 5;
  string source_instance_id = 6;           // empty string represents null
  string source_script = 7;                // empty string represents null
  google.protobuf.Timestamp site_enqueued_at = 8;
  string origin_execution_id = 9;          // GUID string; empty represents null
  string origin_parent_execution_id = 10;  // GUID string; empty represents null
  string source_node = 11;                 // empty string represents null
}

// Central -> Site: ack sent after the Notifications row is persisted.
message NotificationSubmitAckDto {
  string notification_id = 1;
  bool accepted = 2;
  string error = 3;                        // empty string represents null
}

// Site -> Central: Notify.Status(id) lookup against the central outbox.
message NotificationStatusQueryDto {
  string correlation_id = 1;
  string notification_id = 2;
}

// Central -> Site: current central delivery state for a queried notification.
message NotificationStatusResponseDto {
  string correlation_id = 1;
  bool found = 2;
  string status = 3;
  int32 retry_count = 4;
  string last_error = 5;                     // empty string represents null
  google.protobuf.Timestamp delivered_at = 6; // absent when null
}

// ---------------------------------------------------------------------------
// Startup reconciliation (Deployment Manager)
// ---------------------------------------------------------------------------

// Site -> Central: the node's local deployed inventory at startup.
message ReconcileSiteRequestDto {
  string site_identifier = 1;
  string node_id = 2;
  // Instance unique name -> revision hash of the config the node currently holds.
  map<string, string> local_name_to_revision_hash = 3;
}

// Central -> Site: the gap the node must (re)fetch, plus orphans to log.
message ReconcileSiteResponseDto {
  repeated ReconcileGapItemDto gap = 1;
  repeated string orphan_names = 2;
  string central_fetch_base_url = 3;
}

// One instance the node must (re)fetch, with a freshly-minted short-TTL token.
message ReconcileGapItemDto {
  string instance_unique_name = 1;
  string deployment_id = 2;
  string revision_hash = 3;
  bool is_enabled = 4;
  string fetch_token = 5;
}

// ---------------------------------------------------------------------------
// Health Monitoring (#11)
// ---------------------------------------------------------------------------

// Wire form of the Commons ConnectionHealth enum.
//
// CONNECTION_HEALTH_UNSPECIFIED exists only to keep the proto3 zero value from
// meaning something. Mapping Connected onto 0 would make an absent/garbled
// value decode as "healthy", which is precisely the wrong direction to fail;
// the mapper decodes UNSPECIFIED as Error instead and never emits it.
enum ConnectionHealthEnum {
  CONNECTION_HEALTH_UNSPECIFIED = 0;
  CONNECTION_HEALTH_CONNECTED = 1;
  CONNECTION_HEALTH_DISCONNECTED = 2;
  CONNECTION_HEALTH_CONNECTING = 3;
  CONNECTION_HEALTH_ERROR = 4;
}

message TagResolutionStatusDto {
  int32 total_subscribed = 1;
  int32 successfully_resolved = 2;
}

message TagQualityCountsDto {
  int32 good = 1;
  int32 bad = 2;
  int32 uncertain = 3;
}

message NodeStatusDto {
  string hostname = 1;
  bool is_online = 2;
  string role = 3;
}

// Point-in-time snapshot of the site-local SQLite audit queue.
message SiteAuditBacklogSnapshotDto {
  int32 pending_count = 1;
  google.protobuf.Timestamp oldest_pending_utc = 2;  // absent when the queue is empty
  int64 on_disk_bytes = 3;
}

// The three collection wrappers below exist so null and empty stay
// distinguishable. proto3 cannot express presence on a `repeated` or `map`
// field — an unset one and an empty one are the same bytes — but the
// corresponding SiteHealthReport members are genuinely nullable
// (SiteHealthCollector emits `ClusterNodes: _clusterNodes?.ToList()`), and the
// central health surface reads null as "this producer doesn't report the
// signal" rather than "the signal is empty". Wrapping in a message restores
// message presence and makes the distinction survive the round-trip.

message ConnectionEndpointMapDto {
  map<string, string> entries = 1;
}

message TagQualityMapDto {
  map<string, TagQualityCountsDto> entries = 1;
}

message NodeStatusListDto {
  repeated NodeStatusDto nodes = 1;
}

// Site -> Central: periodic site health report. Mirrors Commons SiteHealthReport.
// Additive-only evolution: field numbers are never reused.
message SiteHealthReportDto {
  string site_id = 1;
  int64 sequence_number = 2;
  google.protobuf.Timestamp report_timestamp = 3;
  map<string, ConnectionHealthEnum> data_connection_statuses = 4;
  map<string, TagResolutionStatusDto> tag_resolution_counts = 5;
  int32 script_error_count = 6;
  int32 alarm_evaluation_error_count = 7;
  map<string, int32> store_and_forward_buffer_depths = 8;
  int32 dead_letter_count = 9;
  int32 deployed_instance_count = 10;
  int32 enabled_instance_count = 11;
  int32 disabled_instance_count = 12;
  string node_role = 13;
  string node_hostname = 14;
  ConnectionEndpointMapDto data_connection_endpoints = 15;   // absent when null
  TagQualityMapDto data_connection_tag_quality = 16;         // absent when null
  int32 parked_message_count = 17;
  NodeStatusListDto cluster_nodes = 18;                      // absent when null
  int32 site_audit_write_failures = 19;
  int32 audit_redaction_failure = 20;
  SiteAuditBacklogSnapshotDto site_audit_backlog = 21;       // absent when no data yet
  int64 site_event_log_write_failures = 22;
  google.protobuf.DoubleValue oldest_parked_message_age_seconds = 23;  // absent when nothing parked
  int32 script_queue_depth = 24;
  int32 script_busy_threads = 25;
  google.protobuf.DoubleValue script_oldest_busy_age_seconds = 26;     // absent when the pool is idle
  // Nullable on purpose: absent means "replication not wired on this node",
  // which is NOT the same as false ("wired but currently disconnected").
  google.protobuf.BoolValue local_db_replication_connected = 27;
  // Absent means UNKNOWN, never zero — a failed backlog read rendered as 0
  // would report a broken replication pair as perfectly healthy.
  google.protobuf.Int64Value local_db_oplog_backlog = 28;
}

// Central -> Site: health report ack, so a lost report is observable.
message SiteHealthReportAckDto {
  string site_id = 1;
  int64 sequence_number = 2;
  bool accepted = 3;
  string error = 4;                        // empty string represents null
}

// Site -> Central: application heartbeat (fire-and-forget; reply is Empty).
message HeartbeatDto {
  string site_id = 1;
  string node_hostname = 2;
  bool is_active = 3;
  google.protobuf.Timestamp timestamp = 4;
  // Additive (field 5): true marks a SYNTHETIC heartbeat that carries no liveness
  // meaning — today only CentralChannelProvider's failback probe, which reuses this
  // RPC purely to test whether the preferred central endpoint answers again. Central
  // must skip ALL liveness/health bookkeeping for such a heartbeat: it does not come
  // from a site node's heartbeat timer, and treating it as one would keep a site
  // looking alive on the health dashboard from the probe alone. proto3 defaults it to
  // false, so a pre-existing site's heartbeat is a real one, as before.
  bool synthetic = 5;
}
