using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Nodes;
using CareFix.Api.Ai;
using CareFix.Api.Hospitals;
using CareFix.Api.Infrastructure;
using CareFix.Api.Knowledge;
using CareFix.Api.Options;
using CareFix.Api.Security;
using CareFix.Core;
using CareFix.Api.Tickets;
using Dapper;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
namespace CareFix.Api.Fixes;
public sealed class FixRow
{
public int FixId { get; set; }
public int TicketId { get; set; }
public string Summary { get; set; } = "";
public string Risk { get; set; } = "";
public string State { get; set; } = "";
public bool LockOverrideNeeded { get; set; }
public string? ConsentFileName { get; set; }
}
public sealed class FixStepRow
{
public int StepNo { get; set; }
public string TableName { get; set; } = "";
public string PkColumn { get; set; } = "";
public string PkValue { get; set; } = "";
public string ColumnName { get; set; } = "";
public string? OldValue { get; set; }
public string? NewValue { get; set; }
public string? Reason { get; set; }
}
public sealed class ApprovalRow
{
public int ApproverId { get; set; }
public string ApproverRole { get; set; } = "";
public string Decision { get; set; } = "";
}
/// Proposal, approval, execution, verification and rollback of data fixes.
public sealed class FixService(
ControlDb db, IHospitalExecutor exec, KnowledgeBase kb, SqlGuard guard, TicketService tickets,
AiQueue queue, AuditService audit, IOptions options)
{
// ------------------------------------------------------------------ propose (called by the AI)
public async Task ProposeAsync(TicketRow t, JsonObject input, CancellationToken ct)
{
var s = options.Value.Safety;
var summary = (input["summary"]?.ToString() ?? "").Trim();
var evidence = input["evidence"]?.ToString();
if (summary.Length < 10) throw AppException.BadRequest("summary is required: say in one or two sentences what is wrong and what the fix does.");
if (input["steps"] is not JsonArray stepsNode || stepsNode.Count == 0) throw AppException.BadRequest("steps is required and must not be empty.");
if (stepsNode.Count > s.MaxStepsPerFix) throw AppException.BadRequest($"A fix can change at most {s.MaxStepsPerFix} values. Split the work or escalate to the HIS team.");
var known = await kb.KnownTablesAsync(t.HospitalId, ct);
var steps = new List();
var reasons = new List();
var risk = "Low";
var n = 0;
foreach (var node in stepsNode)
{
n++;
if (node is not JsonObject st) throw AppException.BadRequest($"Step {n} is not an object.");
string Req(string k) => st[k]?.ToString() is { Length: > 0 } v ? v.Trim() : throw AppException.BadRequest($"Step {n}: {k} is required.");
var table = Req("table"); var pkCol = Req("pk_column"); var pk = Req("pk_value"); var col = Req("column");
var newVal = st["new_value"]?.ToString();
var reason = st["reason"]?.ToString();
if (!known.Contains(table)) throw AppException.BadRequest($"Step {n}: table {table} is not in this hospital's schema.");
var colInfo = await kb.ColumnInfoAsync(t.HospitalId, table, col, ct)
?? throw AppException.BadRequest($"Step {n}: column {table}.{col} does not exist.");
if (await kb.ColumnInfoAsync(t.HospitalId, table, pkCol, ct) is null)
throw AppException.BadRequest($"Step {n}: key column {table}.{pkCol} does not exist.");
if (colInfo.IsPk) throw AppException.BadRequest($"Step {n}: key columns cannot be changed.");
if (colInfo.Editable == false)
throw AppException.BadRequest($"Step {n}: {table}.{col} is marked not editable in the Caresoft data dictionary. Use another approach or recommend escalation.");
if (newVal is null && !colInfo.IsNullable) throw AppException.BadRequest($"Step {n}: {table}.{col} does not allow NULL.");
var read = await exec.GetValueAsync(t.HospitalId, table, pkCol, pk, col, ct);
if (read.Rows != 1) throw AppException.BadRequest($"Step {n}: {pkCol} = '{pk}' matches {read.Rows} rows in {table}. Use a key that identifies exactly one row.");
if (read.Value == newVal) throw AppException.BadRequest($"Step {n}: the new value is the same as the current value ('{read.Value}').");
var stepRisk = colInfo.RiskLevel ?? "Medium";
if (colInfo.Meaning is null) reasons.Add($"{table}.{col} is not described in the data dictionary");
if (RiskRules.IsAmountColumn(col)) { stepRisk = "High"; reasons.Add($"{table}.{col} affects amounts or quantities"); }
risk = RiskRules.Max(risk, stepRisk);
steps.Add(new FixStepRow { StepNo = n, TableName = table, PkColumn = pkCol, PkValue = pk, ColumnName = col, OldValue = read.Value, NewValue = newVal, Reason = reason });
}
if (steps.Count >= s.HighRiskStepThreshold) { risk = "High"; reasons.Add($"{steps.Count} values change in one fix"); }
var lockOverride = false;
foreach (var g in steps.GroupBy(x => (x.TableName.ToUpperInvariant(), x.PkValue)))
{
var first = g.First();
foreach (var rule in await kb.LockRulesAsync(first.TableName, ct))
{
var check = guard.ValidateSelect(rule.LockCheckSql, known);
if (!check.Ok) { reasons.Add($"lock rule {rule.RuleId} could not be checked ({check.Reason})"); risk = "High"; continue; }
if (await exec.AnyRowsAsync(t.HospitalId, rule.LockCheckSql, first.PkValue, ct))
{
lockOverride = true; risk = "High";
reasons.Add($"LOCKED: {rule.LockMessage ?? rule.RuleText} ({first.TableName} {first.PkValue})");
}
}
}
await using var c = await db.OpenAsync(ct);
await using var tx = (SqlTransaction)await c.BeginTransactionAsync(ct);
await c.ExecuteAsync("UPDATE CF_FIX SET State='Superseded' WHERE TicketId=@TicketId AND State='Proposed'", new { t.TicketId }, tx);
var fixId = await c.ExecuteScalarAsync("""
INSERT CF_FIX (TicketId, Summary, Evidence, Risk, RiskReasons, RowsExpected, LockOverrideNeeded)
OUTPUT INSERTED.FixId VALUES (@TicketId, @summary, @evidence, @risk, @reasons, @rows, @lockOverride)
""", new { t.TicketId, summary, evidence, risk, reasons = reasons.Count == 0 ? null : string.Join("; ", reasons.Distinct()), rows = steps.Count, lockOverride }, tx);
foreach (var st in steps)
await c.ExecuteAsync("""
INSERT CF_FIX_STEP (FixId, StepNo, TableName, PkColumn, PkValue, ColumnName, OldValue, NewValue, Reason)
VALUES (@fixId, @StepNo, @TableName, @PkColumn, @PkValue, @ColumnName, @OldValue, @NewValue, @Reason)
""", new { fixId, st.StepNo, st.TableName, st.PkColumn, st.PkValue, st.ColumnName, st.OldValue, st.NewValue, st.Reason }, tx);
await tx.CommitAsync(ct);
await tickets.SetStateAsync(t.TicketId, "FixProposed", risk, ct);
await tickets.AddMessageAsync(t.TicketId, "System", $"Fix #{fixId} proposed ({steps.Count} value(s), {risk} risk). Needs: {ApprovalRules.RuleText(risk)}.", null, ct);
await audit.LogAsync(null, t.TicketId, "FixProposed", new { fixId, risk, steps = steps.Count, lockOverride }, ct);
return $"Fix #{fixId} saved with {risk} risk{(lockOverride ? " (lock override needed)" : "")}. Approval needed: {ApprovalRules.RuleText(risk)}. " +
"Do not propose it again. Tell the engineer in 2-3 short sentences what will change and why, then stop.";
}
// ------------------------------------------------------------------ approvals
private async Task<(FixRow fix, TicketRow ticket)> LoadAsync(ClaimsPrincipal user, int fixId, CancellationToken ct)
{
await using var c = await db.OpenAsync(ct);
var fix = await c.QuerySingleOrDefaultAsync(
"SELECT FixId, TicketId, Summary, Risk, State, LockOverrideNeeded, ConsentFileName FROM CF_FIX WHERE FixId=@fixId", new { fixId })
?? throw AppException.NotFound("Fix not found.");
var ticket = await tickets.GetForUserAsync(user, fix.TicketId, ct);
return (fix, ticket);
}
public async Task