File size: 10,559 Bytes
f453a26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * Request Input Validation
 * Pure parsing helpers shared by the HTTP API and the Telegram bot.
 * Each parser returns a normalized value or throws InputValidationError.
 */

export class InputValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "InputValidationError";
  }
}

export const HORIZON_MIN_YEARS = 1;
export const HORIZON_MAX_YEARS = 30;
export const VAR_MAX_PERIODS = 40;
export const QUERY_MAX_LENGTH = 1000;

export const LIVE_WIRE_CATEGORIES = [
  "all",
  "sovereign_decisions",
  "economic",
  "political",
  "local",
  "international",
  "saudi_gulf",
  "global_macro",
  "energy_markets",
  "trade_policy",
] as const;

export type LiveWireCategory = (typeof LIVE_WIRE_CATEGORIES)[number];

const ISO3_REGEX = /^[A-Z]{3}$/;
const INDICATOR_REGEX = /^[A-Za-z0-9._]{1,40}$/;
const TELEGRAM_TOKEN_REGEX = /^\d{5,15}:[A-Za-z0-9_-]{30,64}$/;

function firstValue(raw: unknown): unknown {
  return Array.isArray(raw) ? raw[0] : raw;
}

const COUNTRY_MAP: Record<string, string> = {
  // Saudi Arabia
  SAU: "SAU", SA: "SAU", KSA: "SAU", SAUDI: "SAU", "SAUDI ARABIA": "SAU",
  "السعودية": "SAU", "المملكة العربية السعودية": "SAU", "المملكة": "SAU", "الرياض": "SAU",

  // Egypt
  EGY: "EGY", EG: "EGY", EGYPT: "EGY", "مصر": "EGY", "جمهورية مصر العربية": "EGY", "القاهرة": "EGY",

  // United States
  USA: "USA", US: "USA", AMERICA: "USA", "UNITED STATES": "USA", "UNITED STATES OF AMERICA": "USA",
  "أمريكا": "USA", "امريكا": "USA", "الولايات المتحدة": "USA", "الولايات المتحدة الأمريكية": "USA", "واشنطن": "USA",

  // United Arab Emirates
  ARE: "ARE", UAE: "ARE", EMIRATES: "ARE", "الإمارات": "ARE", "الامارات": "ARE", "الإمارات العربية المتحدة": "ARE", "أبوظبي": "ARE", "دبي": "ARE",

  // Qatar
  QAT: "QAT", QA: "QAT", QATAR: "QAT", "قطر": "QAT", "دولة قطر": "QAT", "الدوحة": "QAT",

  // Kuwait
  KWT: "KWT", KW: "KWT", KUWAIT: "KWT", "الكويت": "KWT", "دولة الكويت": "KWT",

  // Oman
  OMN: "OMN", OM: "OMN", OMAN: "OMN", "عمان": "OMN", "سلطنة عمان": "OMN", "مسقط": "OMN",

  // Bahrain
  BHR: "BHR", BH: "BHR", BAHRAIN: "BHR", "البحرين": "BHR", "مملكة البحرين": "BHR",

  // Jordan
  JOR: "JOR", JO: "JOR", JORDAN: "JOR", "الأردن": "JOR", "الاردن": "JOR", "المملكة الأردنية الهاشمية": "JOR",

  // Turkey
  TUR: "TUR", TR: "TUR", TURKEY: "TUR", TURKIYE: "TUR", "TÜRKIYE": "TUR", "تركيا": "TUR", "أنقرة": "TUR",

  // Germany
  DEU: "DEU", DE: "DEU", GERMANY: "DEU", DEUTSCHLAND: "DEU", "ألمانيا": "DEU", "المانيا": "DEU", "برلين": "DEU",

  // Japan
  JPN: "JPN", JP: "JPN", JAPAN: "JPN", "اليابان": "JPN", "طوكيو": "JPN",

  // United Kingdom
  GBR: "GBR", GB: "GBR", UK: "GBR", "GREAT BRITAIN": "GBR", "UNITED KINGDOM": "GBR", "المملكة المتحدة": "GBR", "بريطانيا": "GBR", "لندن": "GBR",

  // China
  CHN: "CHN", CN: "CHN", CHINA: "CHN", "الصين": "CHN", "بكين": "CHN",

  // Russia
  RUS: "RUS", RU: "RUS", RUSSIA: "RUS", "روسيا": "RUS", "موسكو": "RUS",

  // Iran
  IRN: "IRN", IR: "IRN", IRAN: "IRN", "إيران": "IRN", "ايران": "IRN", "طهران": "IRN",

  // Iraq
  IRQ: "IRQ", IQ: "IRQ", IRAQ: "IRQ", "العراق": "IRQ", "بغداد": "IRQ",

  // Syria
  SYR: "SYR", SY: "SYR", SYRIA: "SYR", "سوريا": "SYR", "دمشق": "SYR",

  // Lebanon
  LBN: "LBN", LB: "LBN", LEBANON: "LBN", "لبنان": "LBN", "بيروت": "LBN",

  // Palestine
  PSE: "PSE", PS: "PSE", PALESTINE: "PSE", "فلسطين": "PSE", "القدس": "PSE", "غزة": "PSE",

  // Israel
  ISR: "ISR", IL: "ISR", ISRAEL: "ISR", "إسرائيل": "ISR", "اسرائيل": "ISR", "تل أبيب": "ISR", "نتنياهو": "ISR", "NETANYAHU": "ISR",

  // Ukraine
  UKR: "UKR", UA: "UKR", UKRAINE: "UKR", "أوكرانيا": "UKR", "اوكرانيا": "UKR", "كييف": "UKR",

  // Norway & Finland
  NOR: "NOR", NO: "NOR", NORWAY: "NOR", "النرويج": "NOR", "أوسلو": "NOR",
  FIN: "FIN", FI: "FIN", FINLAND: "FIN", "فنلندا": "FIN", "هلسنكي": "FIN",

  // Sudan, Morocco, Algeria, Tunisia
  SDN: "SDN", SD: "SDN", SUDAN: "SDN", "السودان": "SDN", "الخرطوم": "SDN",
  MAR: "MAR", MA: "MAR", MOROCCO: "MAR", "المغرب": "MAR", "الرباط": "MAR",
  DZA: "DZA", DZ: "DZA", ALGERIA: "DZA", "الجزائر": "DZA",
  TUN: "TUN", TN: "TUN", TUNISIA: "TUN", "تونس": "TUN",

  // Yemen
  YEM: "YEM", YE: "YEM", YEMEN: "YEM", "اليمن": "YEM", "صنعاء": "YEM",

  // France
  FRA: "FRA", FR: "FRA", FRANCE: "FRA", "فرنسا": "FRA", "باريس": "FRA",

  // India
  IND: "IND", IN: "IND", INDIA: "IND", "الهند": "IND",

  // Brazil
  BRA: "BRA", BR: "BRA", BRAZIL: "BRA", "البرازيل": "BRA",
};

export function detectCountryFromHeadline(text: string, defaultIso: string = "SAU"): string {
  if (!text) return defaultIso;
  const lower = text.toLowerCase();
  for (const [key, isoCode] of Object.entries(COUNTRY_MAP)) {
    if (key.length > 2 && lower.includes(key.toLowerCase())) {
      return isoCode;
    }
  }
  return defaultIso;
}

export function parseIso3(raw: unknown, fallback?: string): string {
  const value = firstValue(raw);
  if (value === undefined || value === null || value === "") {
    if (fallback !== undefined) return fallback;
    throw new InputValidationError("country is required");
  }
  if (typeof value !== "string") {
    throw new InputValidationError("country must be a string");
  }
  const str = value.trim();
  if (!str) {
    if (fallback !== undefined) return fallback;
    throw new InputValidationError("country cannot be empty");
  }
  const upper = str.toUpperCase();
  if (ISO3_REGEX.test(upper)) {
    return upper;
  }
  // Check full country name if key is longer than 2 characters (e.g. SAUDI ARABIA, GERMANY, مصر)
  if (COUNTRY_MAP[upper] && upper.length > 2) {
    return COUNTRY_MAP[upper];
  }
  if (COUNTRY_MAP[str] && str.length > 2) {
    return COUNTRY_MAP[str];
  }
  throw new InputValidationError(`Invalid country ISO3 code: ${str}`);
}

export function parseHorizonYears(raw: unknown, fallback: number = 15): number {
  const value = firstValue(raw);
  if (value === undefined || value === null || value === "") return fallback;
  const num = typeof value === "number" ? value : Number(value);
  if (!Number.isInteger(num) || num < HORIZON_MIN_YEARS || num > HORIZON_MAX_YEARS) {
    throw new InputValidationError(
      `horizon_years must be an integer between ${HORIZON_MIN_YEARS} and ${HORIZON_MAX_YEARS}`
    );
  }
  return num;
}

export function parseQueryText(raw: unknown, fallback: string, field: string = "query"): string {
  const value = firstValue(raw);
  if (value === undefined || value === null) return fallback;
  if (typeof value !== "string") throw new InputValidationError(`${field} must be a string`);
  const text = value.trim();
  if (!text) return fallback;
  if (text.length > QUERY_MAX_LENGTH) {
    throw new InputValidationError(`${field} must be at most ${QUERY_MAX_LENGTH} characters`);
  }
  return text;
}

export function parseAlpha(raw: unknown, fallback: number = 0.6): number {
  const value = firstValue(raw);
  if (value === undefined || value === null || value === "") return fallback;
  const num = Number(value);
  if (!Number.isFinite(num) || num < 0 || num > 1) {
    throw new InputValidationError("alpha must be a number between 0 and 1");
  }
  return num;
}

export function parseIndicatorCode(raw: unknown, fallback: string): string {
  const value = firstValue(raw);
  if (value === undefined || value === null || value === "") return fallback;
  if (typeof value !== "string" || !INDICATOR_REGEX.test(value.trim())) {
    throw new InputValidationError("indicator must be an alphanumeric series code (e.g. NY.GDP.MKTP.KD.ZG)");
  }
  return value.trim();
}

export function parseMacroSource(raw: unknown): "WorldBank" | "IMF" | "FRED" {
  const value = firstValue(raw);
  if (value === undefined || value === null || value === "") return "WorldBank";
  if (value === "WorldBank" || value === "IMF" || value === "FRED") return value;
  throw new InputValidationError("source must be one of WorldBank, IMF, FRED");
}

export function parseLiveWireCategory(raw: unknown): LiveWireCategory {
  const value = firstValue(raw);
  if (value === undefined || value === null || value === "") return "all";
  if (typeof value === "string" && (LIVE_WIRE_CATEGORIES as readonly string[]).includes(value)) {
    return value as LiveWireCategory;
  }
  throw new InputValidationError(`category must be one of: ${LIVE_WIRE_CATEGORIES.join(", ")}`);
}


export function isValidTelegramBotToken(token: unknown): token is string {
  return typeof token === "string" && TELEGRAM_TOKEN_REGEX.test(token.trim());
}

/**
 * Clamps a numeric value into [min, max], falling back when it is not a finite number.
 * Used inside simulation templates as a last line of defense against oversized workloads.
 */
export function clampInteger(raw: unknown, min: number, max: number, fallback: number): number {
  const num = Number(raw);
  if (!Number.isFinite(num)) return fallback;
  return Math.min(max, Math.max(min, Math.round(num)));
}

export function parseBoundedNumber(raw: unknown, min: number, max: number, field: string): number | undefined {
  if (raw === undefined || raw === null || raw === "") return undefined;
  const num = typeof raw === "number" ? raw : Number(raw);
  if (!Number.isFinite(num) || num < min || num > max) {
    throw new InputValidationError(`${field} must be a number between ${min} and ${max}`);
  }
  return num;
}

export function parseEnum<T extends string>(raw: unknown, allowed: readonly T[], fallback: T, field: string): T {
  if (raw === undefined || raw === null || raw === "") return fallback;
  if (typeof raw === "string" && (allowed as readonly string[]).includes(raw)) return raw as T;
  throw new InputValidationError(`${field} must be one of: ${allowed.join(", ")}`);
}

/** Like parseIso3, but also accepts "GLOBAL", which the news wire uses for non-country feeds. */
export function parseIso3OrGlobal(raw: unknown, fallback: string): string {
  const value = Array.isArray(raw) ? raw[0] : raw;
  if (typeof value === "string" && value.trim().toUpperCase() === "GLOBAL") return "GLOBAL";
  return parseIso3(raw, fallback);
}