""" Localization module for Bahrain. Single source of truth for all region-specific content: - Crisis resources (hotlines, hospitals, NGOs) - Phone number validation and formatting (+973, 8 digits) - CPR (Civil Personal Record) national ID validation - Date/time formatting (DD/MM/YYYY, AST UTC+3) - Safety disclaimers """ import re from datetime import UTC, date, datetime # ── Country / Region ───────────────────────────────────────────────────────── COUNTRY_CODE = "BH" COUNTRY_NAME = "Bahrain" PHONE_COUNTRY_CODE = "+973" TIMEZONE = "Asia/Bahrain" # AST — UTC+3 LOCALE_CODE = "en-GB" # British English for DD/MM/YYYY formatting # ── Bahrain Crisis Resources ───────────────────────────────────────────────── # Sourced and verified from: # - Bahrain Ministry of Health (moh.gov.bh) # - gov.uk Mental health support for UK nationals in Bahrain # - bahrain.bh Emergency Call Centre EMERGENCY_NUMBER = "999" # Police, ambulance, fire — toll-free, 24/7 CHILD_PROTECTION = "998" # Child Protection Centre — toll-free, 24/7 CRISIS_RESOURCES = [ { "id": "emergency", "name": "National Emergency", "name_short": "Emergency", "phone": "999", "phone_display": "999", "description": "Police, ambulance, or fire. Call when immediate help is needed. Toll-free, 24/7.", "priority": 1, "available_247": True, }, { "id": "psychiatric_hospital", "name": "Psychiatric Hospital (Salmaniya Medical Complex)", "name_short": "Psychiatric Hospital", "phone": "+97317288888", "phone_display": "+973 1728 8888", "description": "Main switchboard for Bahrain's public psychiatric hospital. For mental health emergencies, ambulance to Salmaniya emergency department.", "priority": 2, "available_247": True, }, { "id": "psychiatric_appointments", "name": "Psychiatric Hospital — Appointments & Referrals", "name_short": "Psychiatric Appointments", "phone": "+97317279311", "phone_display": "+973 1727 9311", "description": "Schedule an appointment with a psychiatrist at Salmaniya.", "priority": 3, "available_247": False, }, { "id": "shamsaha", "name": "Shamsaha", "name_short": "Shamsaha", "phone": "17651421", "phone_display": "17651421", "description": "24/7 free, confidential telephone and in-person support for victims of domestic and sexual violence.", "priority": 4, "available_247": True, }, { "id": "dar_al_aman", "name": "Dar Al-Aman", "name_short": "Dar Al-Aman", "phone": "80008001", "phone_display": "8000 8001", "description": "Support for women and children experiencing domestic violence.", "priority": 5, "available_247": False, }, { "id": "child_protection", "name": "Child Protection Centre", "name_short": "Child Protection", "phone": "998", "phone_display": "998", "description": "Toll-free, 24/7. Receives reports of violence, abuse, or danger to children.", "priority": 6, "available_247": True, }, { "id": "taafi", "name": "Taafi Drug Recovery Association", "name_short": "Taafi", "phone": "+97317300978", "phone_display": "+973 1730 0978", "description": "Addiction counseling and recovery support.", "priority": 7, "available_247": False, }, ] CRISIS_RESOURCES_BY_PRIORITY = sorted(CRISIS_RESOURCES, key=lambda r: r["priority"]) # ── Safety Disclaimer ──────────────────────────────────────────────────────── SAFETY_DISCLAIMER = ( "This tool is a research prototype. It is not FDA-cleared, CE-marked, or approved " "by any regulatory body. It is not a diagnostic instrument. " "This is a screening companion — not a medical diagnosis. What it shows is one " "small window into how you're doing, and a clinician can see much more. In Bahrain, " "if things feel urgent, 999 is there 24/7, and Salmaniya Medical Complex's " "psychiatric emergency department is available any time." ) CRISIS_RESPONSE = """I hear you, and what you're carrying is real. Thank you for saying it out loud — that took courage. Right now, you don't have to do anything big. Just one small thing. Take a slow breath if you can. Notice where you are. Maybe get a glass of water. You said something really heavy, and I want to sit with you for a moment rather than rush past it. Feelings like this can come in waves — they don't always mean what they feel like they mean in the worst moments. Is there one person you trust who you could reach out to tonight? A family member, a friend, or someone you could sit next to? --- _And whenever you're ready — these are here for you, 24/7:_ - **Shamsaha** — 17651421 — free, confidential, just to talk - **999** — if you're in immediate danger - **Salmaniya Psychiatric Hospital** — +973 1728 8888 - If you're under 18: **Child Protection Centre** — 998 You don't have to know what to say. Just calling is enough. They want to help.""" # ── Phone Number Validation (+973) ─────────────────────────────────────────── BAHRAIN_PHONE_RE = re.compile(r"^\+973\d{8}$") # Mobile: starts with 3, 663, or 669 # Landline: starts with 1 BAHRAIN_MOBILE_RE = re.compile(r"^\+973(3\d{7}|66[39]\d{6})$") BAHRAIN_LANDLINE_RE = re.compile(r"^\+9731\d{7}$") def normalize_phone(phone: str) -> str: """Normalize a phone number to +973XXXXXXXX format. Accepts: '3XXXXXXX', '+973 3XXX XXXX', '973-3XXX-XXXX', '+9733XXXXXXX', etc. Returns: '+9733XXXXXXX' (or raises ValueError). """ if not phone: raise ValueError("Phone number is empty") # Strip spaces, dashes, parens digits = re.sub(r"[\s\-()]+", "", phone.strip()) # If starts with +, keep the plus if digits.startswith("+"): if digits.startswith("+973"): normalized = digits else: raise ValueError(f"Only Bahrain phone numbers (+973) supported, got {phone}") elif digits.startswith("00973"): normalized = "+" + digits[2:] elif digits.startswith("973"): normalized = "+" + digits else: # Assume local 8-digit format if len(digits) == 8 and digits.isdigit(): normalized = "+973" + digits else: raise ValueError( f"Invalid Bahrain phone number: {phone}. Expected 8 digits (local) or +973XXXXXXXX (international)." ) if not BAHRAIN_PHONE_RE.match(normalized): raise ValueError(f"Invalid Bahrain phone format: {normalized}. Must be +973 followed by 8 digits.") return normalized def format_phone_display(phone: str) -> str: """Format a normalized phone number for display: '+973 3XXX XXXX'.""" try: normalized = normalize_phone(phone) digits = normalized[4:] # Strip +973 return f"+973 {digits[:4]} {digits[4:]}" except ValueError: return phone # Return as-is if invalid def classify_phone(phone: str) -> str: """Return 'mobile', 'landline', or 'unknown'.""" try: normalized = normalize_phone(phone) if BAHRAIN_MOBILE_RE.match(normalized): return "mobile" if BAHRAIN_LANDLINE_RE.match(normalized): return "landline" return "unknown" except ValueError: return "unknown" # ── CPR (Civil Personal Record) Validation ─────────────────────────────────── # Bahrain national ID format: YYMMNNNNC (9 digits) # YY = year of birth (00-99, century inferred) # MM = month of birth (01-12) # NNNN = sequence number # C = check digit (Luhn-like modulo) CPR_RE = re.compile(r"^\d{9}$") def _compute_cpr_check_digit(first_8: str) -> int: """Compute the Bahrain CPR check digit. Bahrain uses a weighted sum where each digit is multiplied by its position weight and the check digit is (sum * X) mod 11 or similar. Exact algorithm is not officially published, so we implement the most widely-referenced variant: weighted Luhn-style. Weights: [2, 7, 6, 5, 4, 3, 2, 1] for the 8 digits. Check digit = (11 - (sum of weighted digits mod 11)) mod 11 If result is 10, use 0. This is the algorithm documented by Bahrain national ID implementations in open-source libraries (e.g., python-stdnum). Note: a minority of older CPRs don't follow this exact pattern. """ if len(first_8) != 8 or not first_8.isdigit(): raise ValueError("First 8 characters must be digits") weights = [2, 7, 6, 5, 4, 3, 2, 1] total = sum(int(first_8[i]) * weights[i] for i in range(8)) check = (11 - (total % 11)) % 11 return 0 if check == 10 else check def validate_cpr(cpr: str, strict_check_digit: bool = False) -> bool: """Validate a Bahrain CPR number. Args: cpr: The CPR string (with or without separators). strict_check_digit: If True, enforce the check digit algorithm. If False (default), only validate format and DOB plausibility. Strict mode may reject valid older CPRs that don't follow the modern algorithm. Returns: True if valid format + plausible birth date. """ if not cpr: return False # Strip separators digits = re.sub(r"[\s\-]+", "", cpr.strip()) if not CPR_RE.match(digits): return False # Parse YY and MM year_prefix = int(digits[:2]) month = int(digits[2:4]) if month < 1 or month > 12: return False # Year prefix is 2 digits — we can't fully infer century without more context, # but we can reject implausible years (> current year if assumed 20XX) current_year_2digit = datetime.now(UTC).year % 100 # Allow 19XX or 20XX — both are plausible for a living patient if year_prefix > current_year_2digit and year_prefix < (current_year_2digit + 10): # Could be near future — reject return False if strict_check_digit: try: expected_check = _compute_cpr_check_digit(digits[:8]) actual_check = int(digits[8]) return expected_check == actual_check except (ValueError, IndexError): return False return True def extract_dob_from_cpr(cpr: str) -> tuple[int, int] | None: """Extract (year_4digit, month) from a CPR number. Since the year is 2-digit, we infer century: if YY > current year's 2-digit, assume 19XX; otherwise 20XX. Returns None if invalid. """ if not validate_cpr(cpr): return None digits = re.sub(r"[\s\-]+", "", cpr.strip()) yy = int(digits[:2]) mm = int(digits[2:4]) current_year_full = datetime.now(UTC).year current_yy = current_year_full % 100 # If YY is in the future 2-digit window, it must be last century if yy > current_yy: century = (current_year_full // 100 - 1) * 100 else: century = (current_year_full // 100) * 100 year_full = century + yy return (year_full, mm) def format_cpr_display(cpr: str) -> str: """Format a CPR for display: '850423456' → '8504-2345-6'.""" digits = re.sub(r"[\s\-]+", "", cpr.strip()) if len(digits) != 9 or not digits.isdigit(): return cpr return f"{digits[:4]}-{digits[4:8]}-{digits[8]}" # ── Date Formatting (DD/MM/YYYY) ───────────────────────────────────────────── def format_date(d: date | datetime) -> str: """Format a date as DD/MM/YYYY.""" if isinstance(d, datetime): d = d.date() return d.strftime("%d/%m/%Y") def format_datetime(dt: datetime) -> str: """Format a datetime as DD/MM/YYYY HH:MM (24h).""" return dt.strftime("%d/%m/%Y %H:%M") def format_date_long(d: date | datetime) -> str: """Format a date with full month name: 'DD Month YYYY'.""" if isinstance(d, datetime): d = d.date() return d.strftime("%d %B %Y") # ── Age Calculation ────────────────────────────────────────────────────────── def calculate_age(dob: date | datetime) -> int: """Calculate age in years from date of birth.""" if isinstance(dob, datetime): dob = dob.date() today = date.today() years = today.year - dob.year if (today.month, today.day) < (dob.month, dob.day): years -= 1 return years def validate_dob(dob: date | datetime, min_age: int = 13, max_age: int = 120) -> bool: """Validate that a date of birth produces an age within bounds.""" try: age = calculate_age(dob) return min_age <= age <= max_age except (TypeError, ValueError): return False # ── Language & Culture Metadata ────────────────────────────────────────────── SUPPORTED_LANGUAGES = ["en", "ar"] DEFAULT_LANGUAGE = "en" LANGUAGE_NAMES = { "en": "English", "ar": "العربية", } # Weekend days (Friday = 4, Saturday = 5 in Python's weekday()) WEEKEND_DAYS = [4, 5] def is_weekend(d: date | datetime) -> bool: """Check if a date falls on Friday or Saturday (Bahrain weekend).""" if isinstance(d, datetime): d = d.date() return d.weekday() in WEEKEND_DAYS