using System.Text;
namespace ClawdDotNet.Core.Tasks;
///
/// Liest und schreibt eine Task-Datei: ein YAML-artiger Frontmatter-Block zwischen
/// ----Zeilen, gefolgt vom Rumpf.
///
/// Bewusst ein enger, eigener Parser statt einer YAML-Bibliothek: Die Frontmatter ist
/// ein kleiner, flacher Satz aus Skalaren, einem verschachtelten when, einer
/// Inline-Liste und einem Block-Skalar. Dafür eine Abhängigkeit hereinzuziehen passt
/// nicht zum Stil des Projekts — und ein voller YAML-Parser würde Formen zulassen, die
/// wir gar nicht deuten wollen. Unbekanntes wird übergangen, nicht erraten.
///
public static class TaskFrontmatter
{
///
/// Zerlegt eine Task-Datei in ihre Definition. Gibt false mit einer Meldung
/// zurück, wenn kein Frontmatter-Block gefunden wird — der Ausführungszustand
/// (Marker, Claim) wird hier nicht berührt, er kommt aus der DB.
///
public static bool TryParse(string text, out TaskItem task, out string? error)
{
task = new TaskItem();
error = null;
var normalized = (text ?? "").Replace("\r\n", "\n").Replace("\r", "\n");
// Öffnendes und schließendes "---" finden (BOM/Leerzeilen davor tolerieren).
var lines = normalized.Split('\n');
var open = -1;
for (var i = 0; i < lines.Length; i++)
{
var t = lines[i].TrimStart('').Trim();
if (t.Length == 0) continue;
if (t == "---") { open = i; break; }
break; // erste nicht-leere Zeile ist kein Fence
}
if (open < 0)
{
error = "Kein Frontmatter-Block gefunden (erwartet '---' als erste Zeile).";
return false;
}
var close = -1;
for (var i = open + 1; i < lines.Length; i++)
{
if (lines[i].Trim() == "---") { close = i; break; }
}
if (close < 0)
{
error = "Frontmatter-Block nicht geschlossen (zweites '---' fehlt).";
return false;
}
var block = lines[(open + 1)..close];
var body = string.Join('\n', lines[(close + 1)..]).Trim('\n');
var scalars = new Dictionary(StringComparer.OrdinalIgnoreCase);
var when = new Dictionary(StringComparer.OrdinalIgnoreCase);
var blockedBy = new List();
string? acceptance = null;
var j = 0;
while (j < block.Length)
{
var raw = block[j];
var trimmed = raw.Trim();
if (trimmed.Length == 0 || trimmed.StartsWith('#')) { j++; continue; }
// Nur Zeilen ohne Einrückung sind Top-Level-Schlüssel; eingerückte Zeilen
// gehören zum jeweils vorangehenden Schlüssel und werden dort mitgelesen.
if (Indent(raw) > 0) { j++; continue; }
var colon = trimmed.IndexOf(':');
if (colon < 0) { j++; continue; }
var key = trimmed[..colon].Trim();
var rest = trimmed[(colon + 1)..].Trim();
if (key.Equals("when", StringComparison.OrdinalIgnoreCase) && rest.Length == 0)
{
j = CollectChildren(block, j + 1, child =>
{
var c = child.IndexOf(':');
if (c < 0) return;
when[child[..c].Trim()] = Unquote(child[(c + 1)..].Trim());
});
continue;
}
if (key.Equals("acceptance", StringComparison.OrdinalIgnoreCase) && IsBlockScalar(rest))
{
acceptance = ReadBlockScalar(block, ref j);
continue;
}
if (key.Equals("blocked_by", StringComparison.OrdinalIgnoreCase))
{
if (rest.StartsWith('['))
{
blockedBy.AddRange(ParseInlineList(rest));
j++;
}
else if (rest.Length == 0)
{
// Block-Liste: nachfolgende "- item"-Zeilen.
j = CollectChildren(block, j + 1, child =>
{
var item = child.TrimStart();
if (item.StartsWith('-'))
blockedBy.Add(Unquote(item[1..].Trim()));
});
}
else
{
blockedBy.AddRange(ParseInlineList(rest));
j++;
}
continue;
}
scalars[key] = Unquote(rest);
j++;
}
TaskWhen? whenValue = null;
if (TaskText.WhenKind(when.GetValueOrDefault("kind")) is { } kind)
{
whenValue = new TaskWhen
{
Kind = kind,
Value = when.GetValueOrDefault("value", ""),
TimeZone = when.GetValueOrDefault("tz", "")
};
}
task = new TaskItem
{
Id = scalars.GetValueOrDefault("id", ""),
Title = scalars.GetValueOrDefault("title", ""),
Status = TaskText.Status(scalars.GetValueOrDefault("status")),
Type = TaskText.Type(scalars.GetValueOrDefault("type")),
Priority = ParsePriority(scalars.GetValueOrDefault("priority")),
Assignee = EmptyToDefault(scalars.GetValueOrDefault("assignee"), TaskAssignee.Human),
When = whenValue,
RequireApproval = ParseBool(scalars.GetValueOrDefault("require_approval")),
Acceptance = acceptance?.Trim() ?? "",
BlockedBy = blockedBy,
OnlyWhenMarketOpen = ParseBool(scalars.GetValueOrDefault("onlyWhenMarketOpen")),
ToolName = scalars.GetValueOrDefault("tool_name", ""),
JobTypeId = scalars.GetValueOrDefault("job_type", ""),
CreatedBy = scalars.GetValueOrDefault("created_by", ""),
ParentId = scalars.GetValueOrDefault("parent", ""),
Body = body
};
return true;
}
/// Schreibt eine Definition zurück in Dateiform. Der Ausführungszustand
/// bleibt außen vor — er gehört in die DB, nicht in die Datei.
public static string Serialize(TaskItem task)
{
var sb = new StringBuilder();
sb.Append("---\n");
sb.Append($"id: {Scalar(task.Id)}\n");
sb.Append($"title: {Scalar(task.Title)}\n");
sb.Append($"status: {TaskText.Of(task.Status)}\n");
sb.Append($"type: {TaskText.Of(task.Type)}\n");
sb.Append($"priority: {Math.Clamp(task.Priority, 1, 5)}\n");
sb.Append($"assignee: {Scalar(task.Assignee)}\n");
if (!string.IsNullOrWhiteSpace(task.CreatedBy))
sb.Append($"created_by: {Scalar(task.CreatedBy)}\n");
if (!string.IsNullOrWhiteSpace(task.ParentId))
sb.Append($"parent: {Scalar(task.ParentId)}\n");
if (task.When is { } w)
{
sb.Append("when:\n");
sb.Append($" kind: {TaskText.Of(w.Kind)}\n");
sb.Append($" value: {Scalar(w.Value)}\n");
sb.Append($" tz: {Scalar(w.TimeZone)}\n");
}
// Nur für Poll-Tasks (tool_job): welches Tool mit welcher Job-Art getickt wird.
if (task.Type == TaskItemType.ToolJob || !string.IsNullOrWhiteSpace(task.ToolName))
{
sb.Append($"tool_name: {Scalar(task.ToolName)}\n");
sb.Append($"job_type: {Scalar(task.JobTypeId)}\n");
}
sb.Append($"require_approval: {(task.RequireApproval ? "true" : "false")}\n");
if (!string.IsNullOrWhiteSpace(task.Acceptance))
{
sb.Append("acceptance: |\n");
foreach (var line in task.Acceptance.Replace("\r\n", "\n").Split('\n'))
sb.Append(" ").Append(line).Append('\n');
}
if (task.BlockedBy.Count > 0)
sb.Append($"blocked_by: [{string.Join(", ", task.BlockedBy)}]\n");
sb.Append($"onlyWhenMarketOpen: {(task.OnlyWhenMarketOpen ? "true" : "false")}\n");
sb.Append("---\n");
if (!string.IsNullOrWhiteSpace(task.Body))
sb.Append('\n').Append(task.Body.Replace("\r\n", "\n").TrimEnd('\n')).Append('\n');
return sb.ToString();
}
// ─── Hilfsfunktionen ───
private static int Indent(string line)
{
var n = 0;
while (n < line.Length && line[n] == ' ') n++;
return n;
}
/// Ruft für jede eingerückte Folgezeile auf und
/// gibt den Index der ersten nicht mehr zugehörigen Zeile zurück.
private static int CollectChildren(string[] block, int start, Action onChild)
{
var k = start;
while (k < block.Length)
{
var line = block[k];
if (line.Trim().Length == 0) { k++; continue; }
if (Indent(line) == 0) break;
onChild(line.Trim());
k++;
}
return k;
}
private static bool IsBlockScalar(string rest) => rest is "|" or "|-" or "|+" or ">" or ">-";
/// Liest einen eingerückten Block-Skalar und entfernt die gemeinsame
/// Einrückung. Setzt hinter den Block.
private static string ReadBlockScalar(string[] block, ref int j)
{
var collected = new List();
var k = j + 1;
var commonIndent = int.MaxValue;
while (k < block.Length)
{
var line = block[k];
if (line.Trim().Length == 0) { collected.Add(""); k++; continue; }
if (Indent(line) == 0) break;
commonIndent = Math.Min(commonIndent, Indent(line));
collected.Add(line);
k++;
}
j = k;
if (commonIndent == int.MaxValue) commonIndent = 0;
var sb = new StringBuilder();
foreach (var line in collected)
sb.Append(line.Length >= commonIndent ? line[commonIndent..] : line).Append('\n');
return sb.ToString().TrimEnd('\n');
}
private static IEnumerable ParseInlineList(string rest)
{
var inner = rest.Trim();
if (inner.StartsWith('[')) inner = inner[1..];
if (inner.EndsWith(']')) inner = inner[..^1];
return inner.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(Unquote)
.Where(s => s.Length > 0);
}
private static string Unquote(string value)
{
var v = value.Trim();
if (v.Length >= 2 && v[0] == '"' && v[^1] == '"')
return v[1..^1].Replace("\\\"", "\"").Replace("\\\\", "\\");
if (v.Length >= 2 && v[0] == '\'' && v[^1] == '\'')
return v[1..^1].Replace("''", "'");
return v;
}
/// Quotiert nur, wenn nötig — hält die Datei sonst gut lesbar. Ein führendes
/// @ (Assignee) etwa ist in YAML ein reserviertes Zeichen und muss quotiert
/// werden.
private static string Scalar(string? value)
{
var v = value ?? "";
if (v.Length == 0) return "\"\"";
var needsQuote =
v != v.Trim()
|| "@-[]{}>|*&!%#`,\"'".Contains(v[0])
|| v.Contains(':')
|| v.Contains('#')
|| v.Contains('\n');
if (!needsQuote) return v;
return "\"" + v.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
}
private static bool ParseBool(string? value)
=> value?.Trim().ToLowerInvariant() is "true" or "yes" or "1" or "on";
private static int ParsePriority(string? value)
=> int.TryParse(value?.Trim(), out var p) ? Math.Clamp(p, 1, 5) : 3;
private static string EmptyToDefault(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}